Spaces:
Build error
Build error
Commit Β·
a282d4b
0
Parent(s):
Deploy to HF
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +37 -0
- .env.example +54 -0
- .gitattributes +35 -0
- .github/workflows/ci.yml +114 -0
- .gitignore +29 -0
- .kiro/specs/bankbot-ai-intelligence/.config.kiro +1 -0
- .kiro/specs/bankbot-ai-intelligence/design.md +1393 -0
- .kiro/specs/bankbot-ai-intelligence/tasks.md +286 -0
- .vscode/settings.json +2 -0
- Dockerfile +101 -0
- README.md +186 -0
- backend/Dockerfile +44 -0
- backend/alembic.ini +80 -0
- backend/alembic/env.py +81 -0
- backend/alembic/script.py.mako +26 -0
- backend/app/__init__.py +0 -0
- backend/app/ai/behavior.py +138 -0
- backend/app/ai/budget_planner.py +303 -0
- backend/app/ai/chat.py +289 -0
- backend/app/ai/coaching.py +244 -0
- backend/app/ai/forecasting.py +182 -0
- backend/app/ai/fraud.py +123 -0
- backend/app/ai/fraud_detection.py +286 -0
- backend/app/ai/loan_prediction_model.pkl +3 -0
- backend/app/ai/loan_predictor.py +301 -0
- backend/app/ai/ollama_integration.py +369 -0
- backend/app/ai/router.py +181 -0
- backend/app/ai/simulation.py +204 -0
- backend/app/ai/subscriptions.py +105 -0
- backend/app/ai/voice_assistant.py +219 -0
- backend/app/auth/__init__.py +0 -0
- backend/app/auth/router.py +189 -0
- backend/app/dashboard/__init__.py +0 -0
- backend/app/dashboard/router.py +189 -0
- backend/app/database/database.py +42 -0
- backend/app/database/models.py +147 -0
- backend/app/main.py +171 -0
- backend/app/middleware/__init__.py +0 -0
- backend/app/middleware/cache.py +86 -0
- backend/app/middleware/logging.py +184 -0
- backend/app/notifications/__init__.py +0 -0
- backend/app/notifications/router.py +90 -0
- backend/app/scripts/seed.py +194 -0
- backend/app/scripts/seed_demo.py +300 -0
- backend/app/scripts/test_endpoints.py +249 -0
- backend/app/scripts/test_websocket.py +159 -0
- backend/app/transactions/__init__.py +0 -0
- backend/app/transactions/router.py +60 -0
- backend/app/websocket/connection_manager.py +41 -0
- backend/app/websocket/router.py +142 -0
.dockerignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
backend/venv/
|
| 8 |
+
backend/.venv/
|
| 9 |
+
backend/env/
|
| 10 |
+
backend/*.db
|
| 11 |
+
backend/.env
|
| 12 |
+
|
| 13 |
+
# Node
|
| 14 |
+
frontend/node_modules/
|
| 15 |
+
frontend/.next/
|
| 16 |
+
frontend/.env.local
|
| 17 |
+
frontend/.env.production
|
| 18 |
+
|
| 19 |
+
# Git
|
| 20 |
+
.git/
|
| 21 |
+
.github/
|
| 22 |
+
|
| 23 |
+
# Docs (not needed in container)
|
| 24 |
+
docs/
|
| 25 |
+
.kiro/
|
| 26 |
+
.vscode/
|
| 27 |
+
.temporary_backup/
|
| 28 |
+
|
| 29 |
+
# OS
|
| 30 |
+
.DS_Store
|
| 31 |
+
Thumbs.db
|
| 32 |
+
*.log
|
| 33 |
+
|
| 34 |
+
# Test files
|
| 35 |
+
*.test.ts
|
| 36 |
+
*.spec.ts
|
| 37 |
+
__tests__/
|
.env.example
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# BankBot AI β Environment Configuration
|
| 3 |
+
# ============================================================
|
| 4 |
+
# Copy this file to .env and fill in your values.
|
| 5 |
+
#
|
| 6 |
+
# FALLBACK CHAINS (no config needed for local dev):
|
| 7 |
+
# AI: OpenAI β Groq β Ollama β offline rule-based
|
| 8 |
+
# DB: PostgreSQL β SQLite (auto-fallback)
|
| 9 |
+
# Cache: Redis β in-memory dict (auto-fallback)
|
| 10 |
+
#
|
| 11 |
+
# You only need ONE AI key for full functionality.
|
| 12 |
+
# ============================================================
|
| 13 |
+
|
| 14 |
+
# βββ Database ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
# Leave blank to use SQLite (great for local dev / demo)
|
| 16 |
+
DATABASE_URL=postgresql://admin:adminpassword@localhost:5432/bankbot
|
| 17 |
+
|
| 18 |
+
# Force SQLite regardless of DATABASE_URL
|
| 19 |
+
USE_SQLITE=false
|
| 20 |
+
|
| 21 |
+
# βββ Redis Cache βββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
# Leave blank to use in-memory cache (auto-fallback)
|
| 23 |
+
REDIS_URL=redis://localhost:6379/0
|
| 24 |
+
|
| 25 |
+
# βββ AI Backends (Priority: OpenAI β Groq β Ollama β offline)
|
| 26 |
+
# Priority 1: OpenAI β fastest, most capable
|
| 27 |
+
# Get key: https://platform.openai.com/api-keys
|
| 28 |
+
OPENAI_API_KEY=sk-your-openai-key-here
|
| 29 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 30 |
+
|
| 31 |
+
# Priority 2: Groq β free tier, very fast inference
|
| 32 |
+
# Get key: https://console.groq.com/keys
|
| 33 |
+
GROQ_API_KEY=gsk_your-groq-key-here
|
| 34 |
+
|
| 35 |
+
# Priority 3: Local Ollama β fully offline, no API key
|
| 36 |
+
# Install: https://ollama.com β then: ollama pull llama3
|
| 37 |
+
OLLAMA_MODEL=llama3:latest
|
| 38 |
+
|
| 39 |
+
# βββ Authentication βββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
# IMPORTANT: Change this in production!
|
| 41 |
+
# Generate: python -c "import secrets; print(secrets.token_hex(32))"
|
| 42 |
+
JWT_SECRET_KEY=bankbot-dev-secret-change-in-production
|
| 43 |
+
JWT_ALGORITHM=HS256
|
| 44 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 45 |
+
|
| 46 |
+
# βββ CORS ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
# JSON array of allowed frontend origins
|
| 48 |
+
# Production example: ["https://bankbot-ai.vercel.app"]
|
| 49 |
+
BACKEND_CORS_ORIGINS=["http://localhost:3000"]
|
| 50 |
+
|
| 51 |
+
# βββ Frontend ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
# Backend API URL (no trailing slash)
|
| 53 |
+
# Production: https://bankbot-api.onrender.com
|
| 54 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: BankBot AI β CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main, develop]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
# βββ Backend ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
backend:
|
| 12 |
+
name: Backend β Lint & Import Check
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
defaults:
|
| 15 |
+
run:
|
| 16 |
+
working-directory: backend
|
| 17 |
+
|
| 18 |
+
steps:
|
| 19 |
+
- uses: actions/checkout@v4
|
| 20 |
+
|
| 21 |
+
- name: Set up Python 3.11
|
| 22 |
+
uses: actions/setup-python@v5
|
| 23 |
+
with:
|
| 24 |
+
python-version: "3.11"
|
| 25 |
+
cache: pip
|
| 26 |
+
|
| 27 |
+
- name: Install dependencies
|
| 28 |
+
run: pip install -r requirements.txt
|
| 29 |
+
|
| 30 |
+
- name: Verify all routers import cleanly
|
| 31 |
+
env:
|
| 32 |
+
USE_SQLITE: "true"
|
| 33 |
+
run: |
|
| 34 |
+
python -c "
|
| 35 |
+
from app.main import app
|
| 36 |
+
routes = [r.path for r in app.routes if hasattr(r,'path')]
|
| 37 |
+
print(f'Routes registered: {len(routes)}')
|
| 38 |
+
assert len(routes) >= 30, f'Expected 30+ routes, got {len(routes)}'
|
| 39 |
+
print('All routers import OK')
|
| 40 |
+
"
|
| 41 |
+
|
| 42 |
+
- name: Verify demo seed script imports
|
| 43 |
+
env:
|
| 44 |
+
USE_SQLITE: "true"
|
| 45 |
+
run: python -c "import app.scripts.seed_demo; print('Seed script OK')"
|
| 46 |
+
|
| 47 |
+
# βββ Frontend βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
frontend:
|
| 49 |
+
name: Frontend β Build & Type Check
|
| 50 |
+
runs-on: ubuntu-latest
|
| 51 |
+
defaults:
|
| 52 |
+
run:
|
| 53 |
+
working-directory: frontend
|
| 54 |
+
|
| 55 |
+
steps:
|
| 56 |
+
- uses: actions/checkout@v4
|
| 57 |
+
|
| 58 |
+
- name: Set up Node.js 20
|
| 59 |
+
uses: actions/setup-node@v4
|
| 60 |
+
with:
|
| 61 |
+
node-version: "20"
|
| 62 |
+
cache: npm
|
| 63 |
+
cache-dependency-path: frontend/package-lock.json
|
| 64 |
+
|
| 65 |
+
- name: Install dependencies
|
| 66 |
+
run: npm ci --legacy-peer-deps
|
| 67 |
+
|
| 68 |
+
- name: Type check
|
| 69 |
+
run: npx tsc --noEmit
|
| 70 |
+
|
| 71 |
+
- name: Lint
|
| 72 |
+
run: npm run lint
|
| 73 |
+
|
| 74 |
+
- name: Production build
|
| 75 |
+
env:
|
| 76 |
+
NEXT_PUBLIC_API_URL: http://localhost:8000
|
| 77 |
+
run: npm run build
|
| 78 |
+
|
| 79 |
+
- name: Verify build output
|
| 80 |
+
run: |
|
| 81 |
+
test -d .next/standalone && echo "Standalone build OK" || echo "No standalone (OK for non-Docker)"
|
| 82 |
+
test -d .next/static && echo "Static assets OK"
|
| 83 |
+
|
| 84 |
+
# βββ Docker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
docker:
|
| 86 |
+
name: Docker β Build Check
|
| 87 |
+
runs-on: ubuntu-latest
|
| 88 |
+
if: github.ref == 'refs/heads/main'
|
| 89 |
+
|
| 90 |
+
steps:
|
| 91 |
+
- uses: actions/checkout@v4
|
| 92 |
+
|
| 93 |
+
- name: Build backend image
|
| 94 |
+
run: docker build -t bankbot-backend:ci ./backend
|
| 95 |
+
|
| 96 |
+
- name: Build frontend image
|
| 97 |
+
run: |
|
| 98 |
+
docker build \
|
| 99 |
+
--build-arg NEXT_PUBLIC_API_URL=http://localhost:8000 \
|
| 100 |
+
-t bankbot-frontend:ci \
|
| 101 |
+
./frontend
|
| 102 |
+
|
| 103 |
+
- name: Smoke test backend container
|
| 104 |
+
run: |
|
| 105 |
+
docker run -d --name backend-test \
|
| 106 |
+
-e USE_SQLITE=true \
|
| 107 |
+
-e JWT_SECRET_KEY=ci-test-secret \
|
| 108 |
+
-p 8000:8000 \
|
| 109 |
+
bankbot-backend:ci
|
| 110 |
+
sleep 10
|
| 111 |
+
curl -f http://localhost:8000/health || exit 1
|
| 112 |
+
curl -f http://localhost:8000/api/status || exit 1
|
| 113 |
+
echo "Backend smoke test passed"
|
| 114 |
+
docker stop backend-test
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.streamlit/
|
| 4 |
+
session.json
|
| 5 |
+
chat_history.json
|
| 6 |
+
users.json
|
| 7 |
+
BankBot_Accuracy_Document.docx
|
| 8 |
+
generate_accuracy.py
|
| 9 |
+
src/
|
| 10 |
+
.env
|
| 11 |
+
.env.local
|
| 12 |
+
.env.production
|
| 13 |
+
.venv
|
| 14 |
+
venv/
|
| 15 |
+
env/
|
| 16 |
+
|
| 17 |
+
# Next.js
|
| 18 |
+
frontend/.next/
|
| 19 |
+
frontend/node_modules/
|
| 20 |
+
frontend/out/
|
| 21 |
+
|
| 22 |
+
# Local OCR Windows Binaries (Version Agnostic)
|
| 23 |
+
poppler-*/
|
| 24 |
+
poppler.zip
|
| 25 |
+
tesseract-setup.exe
|
| 26 |
+
tesseract-ocr/
|
| 27 |
+
*.exe
|
| 28 |
+
*.zip
|
| 29 |
+
*.db
|
.kiro/specs/bankbot-ai-intelligence/.config.kiro
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"specId": "bdc55ba3-7595-4d07-b12c-ac91a1297320", "workflowType": "design-first", "specType": "feature"}
|
.kiro/specs/bankbot-ai-intelligence/design.md
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Design Document: BankBot AI Intelligence & API (Phase 4)
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Phase 4 delivers the complete AI intelligence layer for BankBot β a FastAPI-based backend that
|
| 6 |
+
exposes financial forecasting, behavioral analytics, coaching, fraud detection, simulation, and
|
| 7 |
+
real-time WebSocket chat. The system is built for resilience: it auto-detects and falls back
|
| 8 |
+
across OpenAI β Groq β local Ollama for AI, PostgreSQL β SQLite for persistence, and
|
| 9 |
+
Redis β in-memory TTL cache for caching, so the application runs immediately in any environment.
|
| 10 |
+
|
| 11 |
+
The AI layer is already partially implemented. This design documents the full intended architecture,
|
| 12 |
+
the contracts between modules, the formal specifications for key algorithms, and the integration
|
| 13 |
+
points that must be verified or completed.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## Architecture
|
| 18 |
+
|
| 19 |
+
```mermaid
|
| 20 |
+
graph TD
|
| 21 |
+
FE["Next.js Frontend\n(port 3000)"]
|
| 22 |
+
GW["FastAPI Gateway\n(main.py β port 8000)"]
|
| 23 |
+
AIR["ai/router.py\n(HTTP endpoints)"]
|
| 24 |
+
WSR["websocket/router.py\n(WS endpoint)"]
|
| 25 |
+
CM["websocket/connection_manager.py"]
|
| 26 |
+
|
| 27 |
+
subgraph AI_Engines["AI Engine Modules (backend/app/ai/)"]
|
| 28 |
+
FC["forecasting.py"]
|
| 29 |
+
SIM["simulation.py"]
|
| 30 |
+
BEH["behavior.py"]
|
| 31 |
+
COA["coaching.py"]
|
| 32 |
+
SUB["subscriptions.py"]
|
| 33 |
+
FRD["fraud.py"]
|
| 34 |
+
CHT["chat.py"]
|
| 35 |
+
OLL["ollama_integration.py"]
|
| 36 |
+
end
|
| 37 |
+
|
| 38 |
+
subgraph Infra["Infrastructure Layer"]
|
| 39 |
+
DB["database/database.py\n(PostgreSQL β SQLite fallback)"]
|
| 40 |
+
MDL["database/models.py\n(SQLAlchemy ORM)"]
|
| 41 |
+
CAC["middleware/cache.py\n(Redis β MemoryCache fallback)"]
|
| 42 |
+
end
|
| 43 |
+
|
| 44 |
+
subgraph AI_Backends["External AI Backends"]
|
| 45 |
+
OAI["OpenAI API\n(gpt-4o-mini)"]
|
| 46 |
+
GRQ["Groq API\n(llama-3.3-70b)"]
|
| 47 |
+
LOC["Local Ollama\n(llama3:latest)"]
|
| 48 |
+
end
|
| 49 |
+
|
| 50 |
+
FE -->|"REST /api/ai/*"| GW
|
| 51 |
+
FE -->|"WS /api/ai/chat/ws"| GW
|
| 52 |
+
GW --> AIR
|
| 53 |
+
GW --> WSR
|
| 54 |
+
WSR --> CM
|
| 55 |
+
AIR --> FC & SIM & BEH & COA & SUB & FRD & CHT
|
| 56 |
+
WSR --> CHT
|
| 57 |
+
CHT --> OLL
|
| 58 |
+
COA --> OLL
|
| 59 |
+
OLL -->|"priority 1"| OAI
|
| 60 |
+
OLL -->|"priority 2"| GRQ
|
| 61 |
+
OLL -->|"priority 3"| LOC
|
| 62 |
+
AIR --> CAC
|
| 63 |
+
FC & SIM & BEH & COA & SUB & FRD & CHT --> DB
|
| 64 |
+
DB --> MDL
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Sequence Diagrams
|
| 70 |
+
|
| 71 |
+
### HTTP AI Endpoint Request Flow
|
| 72 |
+
|
| 73 |
+
```mermaid
|
| 74 |
+
sequenceDiagram
|
| 75 |
+
participant C as Client
|
| 76 |
+
participant R as ai/router.py
|
| 77 |
+
participant CAC as cache.py
|
| 78 |
+
participant ENG as AI Engine Module
|
| 79 |
+
participant DB as database.py
|
| 80 |
+
participant LLM as AI Backend (OpenAI/Groq/Ollama)
|
| 81 |
+
|
| 82 |
+
C->>R: GET /api/ai/twin/predict?user_id=X
|
| 83 |
+
R->>CAC: cache.get("ai:twin:predict:X")
|
| 84 |
+
alt Cache Hit
|
| 85 |
+
CAC-->>R: cached JSON
|
| 86 |
+
R-->>C: 200 OK (cached)
|
| 87 |
+
else Cache Miss
|
| 88 |
+
R->>ENG: predict_future_balance(db, user_id)
|
| 89 |
+
ENG->>DB: query Account, Transaction
|
| 90 |
+
DB-->>ENG: ORM objects
|
| 91 |
+
ENG-->>R: result dict
|
| 92 |
+
R->>CAC: cache.set(key, result, ttl=300)
|
| 93 |
+
R-->>C: 200 OK (fresh)
|
| 94 |
+
end
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### WebSocket Streaming Chat Flow
|
| 98 |
+
|
| 99 |
+
```mermaid
|
| 100 |
+
sequenceDiagram
|
| 101 |
+
participant C as Browser Client
|
| 102 |
+
participant WS as websocket/router.py
|
| 103 |
+
participant CM as connection_manager.py
|
| 104 |
+
participant CHT as chat.py
|
| 105 |
+
participant LLM as AI Backend
|
| 106 |
+
|
| 107 |
+
C->>WS: WS connect /api/ai/chat/ws?user_id=X
|
| 108 |
+
WS->>CM: ws_manager.connect(websocket, user_id)
|
| 109 |
+
CM-->>WS: accepted
|
| 110 |
+
C->>WS: send JSON {"type":"chat","message":"..."}
|
| 111 |
+
WS->>CHT: stream_chat_response(db, user_id, prompt)
|
| 112 |
+
CHT->>LLM: streaming completion request
|
| 113 |
+
loop For each token chunk
|
| 114 |
+
LLM-->>CHT: token
|
| 115 |
+
CHT-->>WS: yield chunk
|
| 116 |
+
WS-->>C: send_json {"type":"chat_chunk","content":"..."}
|
| 117 |
+
end
|
| 118 |
+
WS-->>C: send_json {"type":"chat_end"}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### AI Backend Fallback Chain
|
| 122 |
+
|
| 123 |
+
```mermaid
|
| 124 |
+
sequenceDiagram
|
| 125 |
+
participant M as Module (chat/coaching)
|
| 126 |
+
participant OLL as ollama_integration.py
|
| 127 |
+
participant OAI as OpenAI API
|
| 128 |
+
participant GRQ as Groq API
|
| 129 |
+
participant LOC as Local Ollama
|
| 130 |
+
|
| 131 |
+
M->>OLL: get_ai_response(prompt)
|
| 132 |
+
alt OPENAI_API_KEY set
|
| 133 |
+
OLL->>OAI: chat.completions.create(gpt-4o-mini)
|
| 134 |
+
OAI-->>OLL: response
|
| 135 |
+
else GROQ_API_KEY set
|
| 136 |
+
OLL->>GRQ: client.chat.completions.create
|
| 137 |
+
GRQ-->>OLL: response
|
| 138 |
+
else Ollama reachable
|
| 139 |
+
OLL->>LOC: POST /api/chat (llama3:latest)
|
| 140 |
+
LOC-->>OLL: response
|
| 141 |
+
else All backends down
|
| 142 |
+
OLL-->>M: None
|
| 143 |
+
M->>M: get_offline_chat_fallback()
|
| 144 |
+
end
|
| 145 |
+
OLL-->>M: response string
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## Components and Interfaces
|
| 151 |
+
|
| 152 |
+
### Component 1: database/database.py β Resilient DB Engine
|
| 153 |
+
|
| 154 |
+
**Purpose**: Provides a SQLAlchemy engine and session factory with automatic PostgreSQL β SQLite fallback.
|
| 155 |
+
|
| 156 |
+
**Interface**:
|
| 157 |
+
```python
|
| 158 |
+
engine: Engine # SQLAlchemy engine (PostgreSQL or SQLite)
|
| 159 |
+
SessionLocal: sessionmaker # Session factory
|
| 160 |
+
Base: DeclarativeMeta # ORM base class
|
| 161 |
+
|
| 162 |
+
def get_db() -> Generator[Session, None, None]:
|
| 163 |
+
"""FastAPI dependency β yields a DB session, closes on exit."""
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
**Responsibilities**:
|
| 167 |
+
- Read `DATABASE_URL` from env; attempt PostgreSQL connection
|
| 168 |
+
- On `OperationalError`, switch to `sqlite:///./bankbot.db` with `check_same_thread=False`
|
| 169 |
+
- Expose `get_db()` as a FastAPI `Depends` injectable
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
### Component 2: middleware/cache.py β Resilient Cache
|
| 174 |
+
|
| 175 |
+
**Purpose**: Provides a unified `get/set/delete` cache interface backed by Redis or an in-memory TTL dict.
|
| 176 |
+
|
| 177 |
+
**Interface**:
|
| 178 |
+
```python
|
| 179 |
+
class MemoryCache:
|
| 180 |
+
def get(self, key: str) -> Any | None
|
| 181 |
+
def set(self, key: str, value: Any, ttl: int | None = None) -> None
|
| 182 |
+
def delete(self, key: str) -> None
|
| 183 |
+
|
| 184 |
+
class CacheManager:
|
| 185 |
+
def get(self, key: str) -> Any | None
|
| 186 |
+
def set(self, key: str, value: Any, ttl: int | None = None) -> None
|
| 187 |
+
def delete(self, key: str) -> None
|
| 188 |
+
|
| 189 |
+
cache: CacheManager # module-level singleton
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
**Responsibilities**:
|
| 193 |
+
- On startup, attempt `redis.Redis.from_url(...).ping()`
|
| 194 |
+
- On failure, fall back to `MemoryCache` (thread-safe via `threading.Lock`)
|
| 195 |
+
- Serialize/deserialize values as JSON when using Redis
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
### Component 3: ai/forecasting.py β Financial Twin Engine
|
| 200 |
+
|
| 201 |
+
**Purpose**: Computes balance projections, savings/investment growth curves, and scenario comparisons.
|
| 202 |
+
|
| 203 |
+
**Interface**:
|
| 204 |
+
```python
|
| 205 |
+
def get_cashflow_metrics(db: Session, user_id: str, days: int = 90
|
| 206 |
+
) -> tuple[float, float, float]:
|
| 207 |
+
"""Returns (current_balance, avg_daily_income, avg_daily_spending)."""
|
| 208 |
+
|
| 209 |
+
def predict_future_balance(db: Session, user_id: str, projection_days: int = 90
|
| 210 |
+
) -> dict:
|
| 211 |
+
"""Returns chart-ready balance projection for 30/60/90 days."""
|
| 212 |
+
|
| 213 |
+
def forecast_savings_and_investments(db: Session, user_id: str, projection_months: int = 12
|
| 214 |
+
) -> dict:
|
| 215 |
+
"""Returns monthly savings growth, investment growth, and debt decline curves."""
|
| 216 |
+
|
| 217 |
+
def simulate_future_scenarios(db: Session, user_id: str, projection_months: int = 6
|
| 218 |
+
) -> dict:
|
| 219 |
+
"""Returns three scenario trajectories: status_quo, frugal, lifestyle_inflation."""
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
### Component 4: ai/simulation.py β What-If Simulator
|
| 225 |
+
|
| 226 |
+
**Purpose**: Evaluates the financial impact of hypothetical purchases, investment changes, and subscription cancellations.
|
| 227 |
+
|
| 228 |
+
**Interface**:
|
| 229 |
+
```python
|
| 230 |
+
def simulate_purchase_impact(
|
| 231 |
+
db: Session, user_id: str, amount: float, category: str, merchant: str
|
| 232 |
+
) -> dict:
|
| 233 |
+
"""Returns risk_level, projected_balance, emergency_buffer_breached, recommendation."""
|
| 234 |
+
|
| 235 |
+
def simulate_investment_impact(
|
| 236 |
+
db: Session, user_id: str, monthly_sip: float, asset_type: str, lump_sum: float = 0.0
|
| 237 |
+
) -> dict:
|
| 238 |
+
"""Returns 1/3/5-year growth projection, affordability check, risk_level."""
|
| 239 |
+
|
| 240 |
+
def simulate_subscription_cancellation(
|
| 241 |
+
db: Session, user_id: str, subscription_ids: list[str]
|
| 242 |
+
) -> dict:
|
| 243 |
+
"""Returns monthly/yearly savings, goal impact, and recommendation."""
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
### Component 5: ai/behavior.py β Behavioral Analytics Engine
|
| 249 |
+
|
| 250 |
+
**Purpose**: Detects spending patterns (late-night, weekend spikes, dopamine/stress triggers) from transaction history.
|
| 251 |
+
|
| 252 |
+
**Interface**:
|
| 253 |
+
```python
|
| 254 |
+
def analyze_spending_behavior(db: Session, user_id: str, days: int = 90) -> dict:
|
| 255 |
+
"""
|
| 256 |
+
Returns:
|
| 257 |
+
insights: list[str] β human-readable behavioral findings
|
| 258 |
+
metrics: dict β late_night_count, weekend_pct, impulsive_count, etc.
|
| 259 |
+
category_breakdown: dict β spending totals per category
|
| 260 |
+
"""
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
### Component 6: ai/coaching.py β Financial Health Coach
|
| 266 |
+
|
| 267 |
+
**Purpose**: Computes a multi-dimensional Financial Health Score and generates LLM-grounded daily briefings.
|
| 268 |
+
|
| 269 |
+
**Interface**:
|
| 270 |
+
```python
|
| 271 |
+
def calculate_financial_health_score(db: Session, user_id: str) -> dict:
|
| 272 |
+
"""
|
| 273 |
+
Returns overall_score (0-100), per-category sub-scores, LLM explanation,
|
| 274 |
+
and actionable_improvements list.
|
| 275 |
+
"""
|
| 276 |
+
|
| 277 |
+
def generate_daily_briefing(db: Session, user_id: str) -> dict:
|
| 278 |
+
"""
|
| 279 |
+
Returns date, user_name, LLM-generated briefing text, and key metrics dict.
|
| 280 |
+
"""
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
### Component 7: ai/subscriptions.py β Subscription Optimizer
|
| 286 |
+
|
| 287 |
+
**Purpose**: Detects duplicate, unused, and cancellable subscriptions from the `subscriptions` table.
|
| 288 |
+
|
| 289 |
+
**Interface**:
|
| 290 |
+
```python
|
| 291 |
+
def analyze_subscriptions(db: Session, user_id: str) -> dict:
|
| 292 |
+
"""
|
| 293 |
+
Returns subscriptions list, duplicates list, unused_subscriptions list,
|
| 294 |
+
yearly_savings_potential, and risk_analysis per merchant.
|
| 295 |
+
"""
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
---
|
| 299 |
+
|
| 300 |
+
### Component 8: ai/fraud.py β Fraud & Anomaly Detector
|
| 301 |
+
|
| 302 |
+
**Purpose**: Scores individual transactions for fraud risk using rule-based heuristics and logs alerts.
|
| 303 |
+
|
| 304 |
+
**Interface**:
|
| 305 |
+
```python
|
| 306 |
+
def evaluate_transaction_for_fraud(db: Session, transaction_id: str) -> dict:
|
| 307 |
+
"""
|
| 308 |
+
Returns fraud_risk_score (0-100), is_anomalous, explanations list, status string.
|
| 309 |
+
Writes FraudLog to DB if score >= 30.
|
| 310 |
+
"""
|
| 311 |
+
|
| 312 |
+
def get_user_fraud_alerts(db: Session, user_id: str) -> dict:
|
| 313 |
+
"""Returns total_alerts, pending_reviews count, and full alerts list."""
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
### Component 9: ai/chat.py β Contextual Chat Agent
|
| 319 |
+
|
| 320 |
+
**Purpose**: Provides HTTP and streaming chat responses grounded in the user's live financial profile, with session memory.
|
| 321 |
+
|
| 322 |
+
**Interface**:
|
| 323 |
+
```python
|
| 324 |
+
class ChatMemoryManager:
|
| 325 |
+
def get_history(self, user_id: str) -> list[dict]
|
| 326 |
+
def add_message(self, user_id: str, role: str, content: str) -> None
|
| 327 |
+
def clear_history(self, user_id: str) -> None
|
| 328 |
+
|
| 329 |
+
def build_user_context_string(db: Session, user_id: str) -> str:
|
| 330 |
+
"""Assembles a financial profile string from DB for the system prompt."""
|
| 331 |
+
|
| 332 |
+
def get_contextual_system_prompt(db: Session, user_id: str) -> str:
|
| 333 |
+
"""Returns the full system prompt with embedded financial context."""
|
| 334 |
+
|
| 335 |
+
def get_chat_response(db: Session, user_id: str, prompt: str) -> str:
|
| 336 |
+
"""Synchronous HTTP chat β tries OpenAI β Groq β Ollama β offline fallback."""
|
| 337 |
+
|
| 338 |
+
def stream_chat_response(db: Session, user_id: str, prompt: str) -> Generator[str, None, None]:
|
| 339 |
+
"""Streaming generator β yields token chunks for WebSocket delivery."""
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
---
|
| 343 |
+
|
| 344 |
+
### Component 10: ai/ollama_integration.py β AI Backend Abstraction
|
| 345 |
+
|
| 346 |
+
**Purpose**: Abstracts OpenAI, Groq, and local Ollama behind a unified interface; detects available backends at startup.
|
| 347 |
+
|
| 348 |
+
**Interface**:
|
| 349 |
+
```python
|
| 350 |
+
def has_active_ai_backend() -> bool
|
| 351 |
+
def get_ai_response(prompt: str, history: list | None, language: str) -> str | None
|
| 352 |
+
def stream_ai_response(prompt: str, history: list | None, language: str) -> Generator[str, None, None]
|
| 353 |
+
def get_groq_response(prompt, history, model, language) -> str | None
|
| 354 |
+
def stream_groq_response(prompt, history, model, language) -> Generator[str, None, None]
|
| 355 |
+
def get_ollama_response(prompt, history, model, language) -> str | None
|
| 356 |
+
def stream_ollama_response(prompt, history, model, language) -> Generator[str, None, None]
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
### Component 11: websocket/connection_manager.py β WebSocket Registry
|
| 362 |
+
|
| 363 |
+
**Purpose**: Maintains a per-user registry of active WebSocket connections; supports targeted and broadcast messaging.
|
| 364 |
+
|
| 365 |
+
**Interface**:
|
| 366 |
+
```python
|
| 367 |
+
class WebSocketConnectionManager:
|
| 368 |
+
async def connect(self, websocket: WebSocket, user_id: str) -> None
|
| 369 |
+
def disconnect(self, websocket: WebSocket, user_id: str) -> None
|
| 370 |
+
async def send_personal_message(self, message: dict, user_id: str) -> None
|
| 371 |
+
async def broadcast(self, message: dict) -> None
|
| 372 |
+
|
| 373 |
+
ws_manager: WebSocketConnectionManager # module-level singleton
|
| 374 |
+
```
|
| 375 |
+
|
| 376 |
+
---
|
| 377 |
+
|
| 378 |
+
### Component 12: ai/router.py β HTTP API Router
|
| 379 |
+
|
| 380 |
+
**Purpose**: Mounts all `/api/ai/*` HTTP endpoints, applies cache-aside pattern, and delegates to engine modules.
|
| 381 |
+
|
| 382 |
+
**Endpoints**:
|
| 383 |
+
|
| 384 |
+
| Method | Path | Engine Function | Cache TTL |
|
| 385 |
+
|--------|------|-----------------|-----------|
|
| 386 |
+
| GET | `/api/ai/twin/predict` | `predict_future_balance` | 300s |
|
| 387 |
+
| GET | `/api/ai/twin/future` | `forecast_savings_and_investments` | 300s |
|
| 388 |
+
| GET | `/api/ai/twin/scenarios` | `simulate_future_scenarios` | 300s |
|
| 389 |
+
| POST | `/api/ai/simulate/purchase` | `simulate_purchase_impact` | none |
|
| 390 |
+
| POST | `/api/ai/simulate/investment` | `simulate_investment_impact` | none |
|
| 391 |
+
| POST | `/api/ai/simulate/subscription` | `simulate_subscription_cancellation` | none |
|
| 392 |
+
| GET | `/api/ai/behavior/insights` | `analyze_spending_behavior` | 600s |
|
| 393 |
+
| GET | `/api/ai/coaching/briefing` | `generate_daily_briefing` | 3600s |
|
| 394 |
+
| GET | `/api/ai/coaching/score` | `calculate_financial_health_score` | 600s |
|
| 395 |
+
| GET | `/api/ai/subscriptions/optimize` | `analyze_subscriptions` | 600s |
|
| 396 |
+
| GET | `/api/ai/fraud/analysis` | `get_user_fraud_alerts` | none |
|
| 397 |
+
| POST | `/api/ai/fraud/evaluate/{id}` | `evaluate_transaction_for_fraud` | none |
|
| 398 |
+
| POST | `/api/ai/chat` | `get_chat_response` | none |
|
| 399 |
+
|
| 400 |
+
---
|
| 401 |
+
|
| 402 |
+
### Component 13: websocket/router.py β WebSocket Endpoint
|
| 403 |
+
|
| 404 |
+
**Purpose**: Handles the `/api/ai/chat/ws` WebSocket lifecycle, dispatches message types, and streams AI replies.
|
| 405 |
+
|
| 406 |
+
**Message Protocol**:
|
| 407 |
+
|
| 408 |
+
| Direction | JSON Shape | Meaning |
|
| 409 |
+
|-----------|-----------|---------|
|
| 410 |
+
| Client β Server | `{"type":"chat","message":"..."}` | Send a chat prompt |
|
| 411 |
+
| Client β Server | `{"type":"ping"}` | Keepalive |
|
| 412 |
+
| Server β Client | `{"type":"chat_start"}` | AI generation beginning |
|
| 413 |
+
| Server β Client | `{"type":"chat_chunk","content":"..."}` | Streaming token |
|
| 414 |
+
| Server β Client | `{"type":"chat_end"}` | Generation complete |
|
| 415 |
+
| Server β Client | `{"type":"pong"}` | Keepalive reply |
|
| 416 |
+
| Server β Client | `{"type":"error","message":"..."}` | Error notification |
|
| 417 |
+
|
| 418 |
+
---
|
| 419 |
+
|
| 420 |
+
## Data Models
|
| 421 |
+
|
| 422 |
+
### Core ORM Models (database/models.py)
|
| 423 |
+
|
| 424 |
+
```python
|
| 425 |
+
class User(Base):
|
| 426 |
+
id: str (UUID PK)
|
| 427 |
+
email: str (unique)
|
| 428 |
+
password_hash: str
|
| 429 |
+
profile_data: JSON # {"name": str, "phone": str}
|
| 430 |
+
financial_personality: str # "Saver" | "Investor" | "Impulsive Spender" | ...
|
| 431 |
+
ai_personalization_settings: JSON
|
| 432 |
+
created_at: DateTime
|
| 433 |
+
# relationships: accounts, subscriptions, goals, investments, ai_insights, notifications
|
| 434 |
+
|
| 435 |
+
class Account(Base):
|
| 436 |
+
id: str (UUID PK)
|
| 437 |
+
user_id: str (FK β users.id)
|
| 438 |
+
type: str # "checking" | "savings"
|
| 439 |
+
balance: float
|
| 440 |
+
currency: str # default "USD"
|
| 441 |
+
status: str # "active" | "inactive"
|
| 442 |
+
# relationships: transactions
|
| 443 |
+
|
| 444 |
+
class Transaction(Base):
|
| 445 |
+
id: str (UUID PK)
|
| 446 |
+
account_id: str (FK β accounts.id)
|
| 447 |
+
amount: float
|
| 448 |
+
type: str # "credit" | "debit"
|
| 449 |
+
category: str # "Food" | "Shopping" | "Income" | ...
|
| 450 |
+
timestamp: DateTime
|
| 451 |
+
merchant: str
|
| 452 |
+
tags: JSON # list[str]
|
| 453 |
+
ai_generated_metadata: JSON # {"is_recurring": bool, "confidence": float}
|
| 454 |
+
spending_emotion_label: str # "happy" | "regret" | "neutral" | "essential"
|
| 455 |
+
|
| 456 |
+
class Subscription(Base):
|
| 457 |
+
id: str (UUID PK)
|
| 458 |
+
user_id: str (FK β users.id)
|
| 459 |
+
merchant: str
|
| 460 |
+
amount: float
|
| 461 |
+
billing_cycle: str # "monthly" | "yearly"
|
| 462 |
+
active: bool
|
| 463 |
+
ai_usage_detection: JSON # {"usage_frequency": "high"|"medium"|"low"|"none"}
|
| 464 |
+
|
| 465 |
+
class Goal(Base):
|
| 466 |
+
id: str (UUID PK)
|
| 467 |
+
user_id: str (FK β users.id)
|
| 468 |
+
title: str
|
| 469 |
+
target_amount: float
|
| 470 |
+
current_amount: float
|
| 471 |
+
target_date: DateTime
|
| 472 |
+
ai_generated_plan: JSON # {"monthly_saving_required": float, "risk": str}
|
| 473 |
+
|
| 474 |
+
class Investment(Base):
|
| 475 |
+
id: str (UUID PK)
|
| 476 |
+
user_id: str (FK β users.id)
|
| 477 |
+
asset_name: str
|
| 478 |
+
type: str # "stock" | "crypto" | "mutual_fund" | "fd" | "bond"
|
| 479 |
+
amount_invested: float
|
| 480 |
+
current_value: float
|
| 481 |
+
portfolio_allocation: float # percentage
|
| 482 |
+
ai_risk_analysis: JSON # {"risk_level": str, "recommendation": str}
|
| 483 |
+
|
| 484 |
+
class FraudLog(Base):
|
| 485 |
+
id: str (UUID PK)
|
| 486 |
+
transaction_id: str (FK β transactions.id, unique)
|
| 487 |
+
risk_score: float # 0.0 β 1.0
|
| 488 |
+
suspicious_activity_details: str
|
| 489 |
+
status: str # "pending" | "resolved" | "false_positive"
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
### API Response Schemas (Pydantic β ai/router.py)
|
| 493 |
+
|
| 494 |
+
```python
|
| 495 |
+
class PurchaseRequest(BaseModel):
|
| 496 |
+
amount: float
|
| 497 |
+
merchant: str
|
| 498 |
+
category: str
|
| 499 |
+
|
| 500 |
+
class InvestmentRequest(BaseModel):
|
| 501 |
+
monthly_sip: float
|
| 502 |
+
asset_type: str # "stock" | "crypto" | "mutual_fund" | "fd" | "bond"
|
| 503 |
+
lump_sum: float = 0.0
|
| 504 |
+
|
| 505 |
+
class SubscriptionSimulationRequest(BaseModel):
|
| 506 |
+
subscription_ids: list[str]
|
| 507 |
+
|
| 508 |
+
class ChatMessageRequest(BaseModel):
|
| 509 |
+
message: str
|
| 510 |
+
```
|
| 511 |
+
|
| 512 |
+
---
|
| 513 |
+
|
| 514 |
+
## Algorithmic Pseudocode
|
| 515 |
+
|
| 516 |
+
### Algorithm 1: Balance Projection (predict_future_balance)
|
| 517 |
+
|
| 518 |
+
```pascal
|
| 519 |
+
ALGORITHM predict_future_balance(db, user_id, projection_days=90)
|
| 520 |
+
INPUT: db β SQLAlchemy Session
|
| 521 |
+
user_id β string UUID
|
| 522 |
+
projection_days β integer in [1, 365]
|
| 523 |
+
OUTPUT: result β dict with chart_data, projected_balance, insight
|
| 524 |
+
|
| 525 |
+
BEGIN
|
| 526 |
+
// Step 1: Gather cashflow metrics
|
| 527 |
+
(current_balance, daily_income, daily_spending) β get_cashflow_metrics(db, user_id, days=90)
|
| 528 |
+
|
| 529 |
+
// Step 2: Compute net daily cashflow
|
| 530 |
+
net_daily β daily_income - daily_spending
|
| 531 |
+
|
| 532 |
+
// Step 3: Project terminal balance
|
| 533 |
+
projected_balance β MAX(0.0, current_balance + net_daily * projection_days)
|
| 534 |
+
|
| 535 |
+
// Step 4: Compute percentage change
|
| 536 |
+
IF current_balance > 0 THEN
|
| 537 |
+
percent_change β (projected_balance - current_balance) / current_balance * 100
|
| 538 |
+
ELSE
|
| 539 |
+
percent_change β 0.0
|
| 540 |
+
END IF
|
| 541 |
+
|
| 542 |
+
// Step 5: Build chart data (every 5 days)
|
| 543 |
+
chart_data β []
|
| 544 |
+
FOR day β 0 TO projection_days STEP 5 DO
|
| 545 |
+
ASSERT day >= 0 AND day <= projection_days
|
| 546 |
+
val β MAX(0.0, current_balance + net_daily * day)
|
| 547 |
+
chart_data.APPEND({date: now() + day_offset(day), balance: ROUND(val, 2)})
|
| 548 |
+
END FOR
|
| 549 |
+
|
| 550 |
+
ASSERT LENGTH(chart_data) >= 1
|
| 551 |
+
ASSERT chart_data[0].balance = ROUND(current_balance, 2)
|
| 552 |
+
|
| 553 |
+
RETURN {current_balance, projected_balance, percent_change, net_daily, insight, chart_data}
|
| 554 |
+
END
|
| 555 |
+
```
|
| 556 |
+
|
| 557 |
+
**Preconditions:**
|
| 558 |
+
- `user_id` references an existing user with at least one account
|
| 559 |
+
- `projection_days` is a positive integer
|
| 560 |
+
|
| 561 |
+
**Postconditions:**
|
| 562 |
+
- `projected_balance >= 0.0` (floored at zero, no negative balances)
|
| 563 |
+
- `chart_data[0].balance == current_balance` (first point is always current state)
|
| 564 |
+
- `len(chart_data) == ceil(projection_days / 5) + 1`
|
| 565 |
+
|
| 566 |
+
**Loop Invariant:** For each iteration, `day` is a non-negative multiple of 5 and `val >= 0.0`
|
| 567 |
+
|
| 568 |
+
---
|
| 569 |
+
|
| 570 |
+
### Algorithm 2: Financial Health Score (calculate_financial_health_score)
|
| 571 |
+
|
| 572 |
+
```pascal
|
| 573 |
+
ALGORITHM calculate_financial_health_score(db, user_id)
|
| 574 |
+
INPUT: db β SQLAlchemy Session
|
| 575 |
+
user_id β string UUID
|
| 576 |
+
OUTPUT: result β dict with overall_score (0-100), categories, explanation, improvements
|
| 577 |
+
|
| 578 |
+
BEGIN
|
| 579 |
+
// Gather raw data
|
| 580 |
+
accounts β db.query(Account).filter(user_id)
|
| 581 |
+
total_balance β SUM(acc.balance FOR acc IN accounts)
|
| 582 |
+
savings_balance β SUM(acc.balance FOR acc IN accounts WHERE acc.type = "savings")
|
| 583 |
+
(_, daily_income, daily_spending) β get_cashflow_metrics(db, user_id)
|
| 584 |
+
monthly_income β MAX(1000.0, daily_income * 30.4)
|
| 585 |
+
monthly_spending β daily_spending * 30.4
|
| 586 |
+
|
| 587 |
+
// Sub-score 1: Savings Consistency (max 20)
|
| 588 |
+
goal_savings β SUM(g.current_amount FOR g IN goals)
|
| 589 |
+
IF goal_savings > 1000 THEN savings_score β 20
|
| 590 |
+
ELSE IF goal_savings > 0 THEN savings_score β 15
|
| 591 |
+
ELSE savings_score β 10
|
| 592 |
+
END IF
|
| 593 |
+
|
| 594 |
+
// Sub-score 2: Debt Ratio (max 20)
|
| 595 |
+
debt_goals β SUM(g.target - g.current FOR g IN goals WHERE "debt" IN g.title)
|
| 596 |
+
debt_to_income β (debt_goals * 0.05) / monthly_income
|
| 597 |
+
IF debt_to_income > 0.40 THEN debt_score β 5
|
| 598 |
+
ELSE IF debt_to_income > 0.20 THEN debt_score β 12
|
| 599 |
+
ELSE IF debt_to_income > 0.05 THEN debt_score β 18
|
| 600 |
+
ELSE debt_score β 20
|
| 601 |
+
END IF
|
| 602 |
+
|
| 603 |
+
// Sub-score 3: Spending Discipline (max 20)
|
| 604 |
+
savings_rate β (monthly_income - monthly_spending) / monthly_income
|
| 605 |
+
IF savings_rate >= 0.30 THEN discipline_score β 20
|
| 606 |
+
ELSE IF savings_rate >= 0.15 THEN discipline_score β 16
|
| 607 |
+
ELSE IF savings_rate >= 0.0 THEN discipline_score β 12
|
| 608 |
+
ELSE discipline_score β 5
|
| 609 |
+
END IF
|
| 610 |
+
|
| 611 |
+
// Sub-score 4: Emergency Fund (max 20)
|
| 612 |
+
months_buffer β savings_balance / MAX(500.0, monthly_spending)
|
| 613 |
+
IF months_buffer >= 6.0 THEN emergency_score β 20
|
| 614 |
+
ELSE IF months_buffer >= 3.0 THEN emergency_score β 15
|
| 615 |
+
ELSE IF months_buffer >= 1.0 THEN emergency_score β 8
|
| 616 |
+
ELSE emergency_score β 0
|
| 617 |
+
END IF
|
| 618 |
+
|
| 619 |
+
// Sub-score 5: Investment Index (max 10)
|
| 620 |
+
inv_total β SUM(i.current_value FOR i IN investments)
|
| 621 |
+
IF inv_total > 5000 THEN investment_score β 10
|
| 622 |
+
ELSE IF inv_total > 0 THEN investment_score β 6
|
| 623 |
+
ELSE investment_score β 0
|
| 624 |
+
END IF
|
| 625 |
+
|
| 626 |
+
// Sub-score 6: Subscription Efficiency (max 10)
|
| 627 |
+
sub_cost β SUM(monthly_cost(s) FOR s IN active_subscriptions)
|
| 628 |
+
sub_ratio β sub_cost / monthly_income
|
| 629 |
+
IF sub_ratio > 0.10 THEN sub_score β 3
|
| 630 |
+
ELSE IF sub_ratio > 0.05 THEN sub_score β 7
|
| 631 |
+
ELSE sub_score β 10
|
| 632 |
+
END IF
|
| 633 |
+
|
| 634 |
+
// Aggregate
|
| 635 |
+
overall_score β CLAMP(savings_score + debt_score + discipline_score +
|
| 636 |
+
emergency_score + investment_score + sub_score, 0, 100)
|
| 637 |
+
|
| 638 |
+
ASSERT overall_score >= 0 AND overall_score <= 100
|
| 639 |
+
ASSERT savings_score + debt_score + discipline_score + emergency_score +
|
| 640 |
+
investment_score + sub_score = overall_score (before clamping)
|
| 641 |
+
|
| 642 |
+
RETURN {overall_score, categories, explanation (LLM or fallback), improvements}
|
| 643 |
+
END
|
| 644 |
+
```
|
| 645 |
+
|
| 646 |
+
**Preconditions:**
|
| 647 |
+
- `monthly_income` is floored at 1000.0 to prevent division-by-zero
|
| 648 |
+
- `monthly_spending` is floored at 500.0 for emergency fund calculation
|
| 649 |
+
|
| 650 |
+
**Postconditions:**
|
| 651 |
+
- `0 <= overall_score <= 100`
|
| 652 |
+
- All six sub-scores are non-negative and within their declared maximums
|
| 653 |
+
- `improvements` is non-empty (at least one suggestion or "maintain habits" message)
|
| 654 |
+
|
| 655 |
+
---
|
| 656 |
+
|
| 657 |
+
### Algorithm 3: Fraud Risk Scoring (evaluate_transaction_for_fraud)
|
| 658 |
+
|
| 659 |
+
```pascal
|
| 660 |
+
ALGORITHM evaluate_transaction_for_fraud(db, transaction_id)
|
| 661 |
+
INPUT: db β SQLAlchemy Session
|
| 662 |
+
transaction_id β string UUID
|
| 663 |
+
OUTPUT: result β dict with fraud_risk_score, is_anomalous, explanations, status
|
| 664 |
+
|
| 665 |
+
BEGIN
|
| 666 |
+
txn β db.query(Transaction).filter(id = transaction_id).first()
|
| 667 |
+
IF txn IS NULL THEN RETURN {error: "Transaction not found"} END IF
|
| 668 |
+
|
| 669 |
+
account β db.query(Account).filter(id = txn.account_id).first()
|
| 670 |
+
history β last 30 debit transactions for account.user_id (excluding txn)
|
| 671 |
+
|
| 672 |
+
score β 0
|
| 673 |
+
reasons β []
|
| 674 |
+
|
| 675 |
+
// Rule 1: Amount spike detection
|
| 676 |
+
IF history IS NOT EMPTY THEN
|
| 677 |
+
avg_amount β MEAN(h.amount FOR h IN history)
|
| 678 |
+
std_amount β STDDEV(h.amount FOR h IN history)
|
| 679 |
+
IF txn.amount > avg_amount * 3.5 THEN
|
| 680 |
+
score β score + 40
|
| 681 |
+
reasons.APPEND("Amount is 3.5x historical average")
|
| 682 |
+
ELSE IF txn.amount > avg_amount * 2.0 THEN
|
| 683 |
+
score β score + 20
|
| 684 |
+
reasons.APPEND("Amount is 2x historical average")
|
| 685 |
+
END IF
|
| 686 |
+
END IF
|
| 687 |
+
|
| 688 |
+
// Rule 2: Late-night timing (11PM β 4AM)
|
| 689 |
+
hour β txn.timestamp.hour
|
| 690 |
+
IF hour >= 23 OR hour < 4 THEN
|
| 691 |
+
score β score + 25
|
| 692 |
+
reasons.APPEND("Unusual timing: 11PMβ4AM")
|
| 693 |
+
END IF
|
| 694 |
+
|
| 695 |
+
// Rule 3: High-frequency (< 3 minutes since last transaction)
|
| 696 |
+
IF history IS NOT EMPTY THEN
|
| 697 |
+
time_diff β ABS(txn.timestamp - history[0].timestamp).seconds
|
| 698 |
+
IF time_diff < 180 THEN
|
| 699 |
+
score β score + 20
|
| 700 |
+
reasons.APPEND("Multiple transactions within 3 minutes")
|
| 701 |
+
END IF
|
| 702 |
+
END IF
|
| 703 |
+
|
| 704 |
+
// Rule 4: Duplicate detection (same merchant + amount within 10 minutes)
|
| 705 |
+
FOR prev IN history[0..4] DO
|
| 706 |
+
time_diff β ABS(txn.timestamp - prev.timestamp).seconds
|
| 707 |
+
IF prev.merchant = txn.merchant AND prev.amount = txn.amount AND time_diff < 600 THEN
|
| 708 |
+
score β score + 30
|
| 709 |
+
reasons.APPEND("Potential duplicate payment")
|
| 710 |
+
BREAK
|
| 711 |
+
END IF
|
| 712 |
+
END FOR
|
| 713 |
+
|
| 714 |
+
score β MIN(100, score)
|
| 715 |
+
|
| 716 |
+
// Persist if above threshold
|
| 717 |
+
IF score >= 30 AND NOT EXISTS FraudLog(transaction_id) THEN
|
| 718 |
+
db.INSERT FraudLog(transaction_id, risk_score=score/100, details=reasons, status="pending")
|
| 719 |
+
db.COMMIT()
|
| 720 |
+
END IF
|
| 721 |
+
|
| 722 |
+
status β IF score >= 50 THEN "flagged"
|
| 723 |
+
ELSE IF score >= 30 THEN "suspicious"
|
| 724 |
+
ELSE "verified"
|
| 725 |
+
|
| 726 |
+
ASSERT score >= 0 AND score <= 100
|
| 727 |
+
RETURN {transaction_id, fraud_risk_score: score, is_anomalous: score >= 30, explanations: reasons, status}
|
| 728 |
+
END
|
| 729 |
+
```
|
| 730 |
+
|
| 731 |
+
**Preconditions:**
|
| 732 |
+
- `transaction_id` must reference an existing transaction with a valid account
|
| 733 |
+
- History window is capped at 30 transactions to bound computation
|
| 734 |
+
|
| 735 |
+
**Postconditions:**
|
| 736 |
+
- `0 <= fraud_risk_score <= 100`
|
| 737 |
+
- A `FraudLog` row is created if and only if `score >= 30` and no prior log exists
|
| 738 |
+
- `status` is exactly one of `"flagged"`, `"suspicious"`, or `"verified"`
|
| 739 |
+
|
| 740 |
+
**Loop Invariant (duplicate check):** At each iteration, all previously checked transactions were not duplicates
|
| 741 |
+
|
| 742 |
+
---
|
| 743 |
+
|
| 744 |
+
### Algorithm 4: Behavioral Pattern Detection (analyze_spending_behavior)
|
| 745 |
+
|
| 746 |
+
```pascal
|
| 747 |
+
ALGORITHM analyze_spending_behavior(db, user_id, days=90)
|
| 748 |
+
INPUT: db β SQLAlchemy Session
|
| 749 |
+
user_id β string UUID
|
| 750 |
+
days β lookback window in days
|
| 751 |
+
OUTPUT: result β dict with insights, metrics, category_breakdown
|
| 752 |
+
|
| 753 |
+
BEGIN
|
| 754 |
+
account_ids β [acc.id FOR acc IN accounts WHERE user_id]
|
| 755 |
+
txns β debit transactions in last `days` days for account_ids
|
| 756 |
+
|
| 757 |
+
IF txns IS EMPTY THEN RETURN default_empty_result END IF
|
| 758 |
+
|
| 759 |
+
amounts β [t.amount FOR t IN txns]
|
| 760 |
+
avg_txn β MEAN(amounts)
|
| 761 |
+
std_txn β STDDEV(amounts)
|
| 762 |
+
|
| 763 |
+
// Classify each transaction
|
| 764 |
+
FOR t IN txns DO
|
| 765 |
+
hour β t.timestamp.hour
|
| 766 |
+
day_of_week β t.timestamp.weekday()
|
| 767 |
+
|
| 768 |
+
IF hour >= 23 OR hour < 4 THEN late_night_txns.ADD(t) END IF
|
| 769 |
+
IF day_of_week IN {4, 5, 6} THEN weekend_txns.ADD(t) END IF
|
| 770 |
+
|
| 771 |
+
IF t.amount > (avg_txn + 1.5 * std_txn) AND t.category IN {"Shopping","Entertainment","Food"} THEN
|
| 772 |
+
impulsive_txns.ADD(t)
|
| 773 |
+
END IF
|
| 774 |
+
|
| 775 |
+
emotion β LOWER(t.spending_emotion_label OR "")
|
| 776 |
+
IF emotion = "regret" THEN stress_txns.ADD(t)
|
| 777 |
+
ELSE IF emotion IN {"happy","dopamine"} OR (t.category = "Shopping" AND t.amount > avg_txn) THEN
|
| 778 |
+
dopamine_txns.ADD(t)
|
| 779 |
+
END IF
|
| 780 |
+
|
| 781 |
+
category_totals[t.category OR "Other"] += t.amount
|
| 782 |
+
END FOR
|
| 783 |
+
|
| 784 |
+
// Generate insights
|
| 785 |
+
insights β []
|
| 786 |
+
late_night_pct β len(late_night_txns) / len(txns) * 100
|
| 787 |
+
IF late_night_pct > 15 THEN insights.ADD(late_night_warning) END IF
|
| 788 |
+
|
| 789 |
+
weekend_pct β len(weekend_txns) / len(txns) * 100
|
| 790 |
+
IF weekend_pct > 45 AND weekend_avg > weekday_avg * 1.2 THEN
|
| 791 |
+
insights.ADD(weekend_spike_warning)
|
| 792 |
+
END IF
|
| 793 |
+
|
| 794 |
+
IF len(dopamine_txns) > 3 THEN insights.ADD(dopamine_warning) END IF
|
| 795 |
+
IF len(stress_txns) > 0 THEN insights.ADD(stress_warning) END IF
|
| 796 |
+
IF insights IS EMPTY THEN insights.ADD(stable_spending_message) END IF
|
| 797 |
+
|
| 798 |
+
ASSERT len(insights) >= 1
|
| 799 |
+
RETURN {insights, metrics, category_breakdown}
|
| 800 |
+
END
|
| 801 |
+
```
|
| 802 |
+
|
| 803 |
+
**Preconditions:**
|
| 804 |
+
- Only `debit` transactions are analyzed (income credits are excluded)
|
| 805 |
+
- `std_txn` defaults to 0.0 when fewer than 2 transactions exist
|
| 806 |
+
|
| 807 |
+
**Postconditions:**
|
| 808 |
+
- `insights` always contains at least one entry
|
| 809 |
+
- `metrics.weekend_pct` is in range `[0.0, 100.0]`
|
| 810 |
+
- `category_breakdown` values are non-negative floats
|
| 811 |
+
|
| 812 |
+
---
|
| 813 |
+
|
| 814 |
+
### Algorithm 5: Cache-Aside Pattern (ai/router.py)
|
| 815 |
+
|
| 816 |
+
```pascal
|
| 817 |
+
ALGORITHM cache_aside_get(cache_key, ttl, compute_fn, db, user_id)
|
| 818 |
+
INPUT: cache_key β string
|
| 819 |
+
ttl β integer seconds
|
| 820 |
+
compute_fn β function(db, user_id) β dict
|
| 821 |
+
db β Session
|
| 822 |
+
user_id β string
|
| 823 |
+
OUTPUT: result β dict (from cache or freshly computed)
|
| 824 |
+
|
| 825 |
+
BEGIN
|
| 826 |
+
cached β cache.get(cache_key)
|
| 827 |
+
IF cached IS NOT NULL THEN
|
| 828 |
+
RETURN cached
|
| 829 |
+
END IF
|
| 830 |
+
|
| 831 |
+
result β compute_fn(db, user_id)
|
| 832 |
+
cache.set(cache_key, result, ttl=ttl)
|
| 833 |
+
RETURN result
|
| 834 |
+
END
|
| 835 |
+
```
|
| 836 |
+
|
| 837 |
+
**Preconditions:**
|
| 838 |
+
- `compute_fn` is a pure function with no side effects on the DB
|
| 839 |
+
- `ttl > 0`
|
| 840 |
+
|
| 841 |
+
**Postconditions:**
|
| 842 |
+
- Returned value is semantically equivalent whether served from cache or computed fresh
|
| 843 |
+
- Cache is populated after a miss so the next call within TTL is served from cache
|
| 844 |
+
|
| 845 |
+
---
|
| 846 |
+
|
| 847 |
+
### Algorithm 6: Compound Growth Projection (forecast_savings_and_investments)
|
| 848 |
+
|
| 849 |
+
```pascal
|
| 850 |
+
ALGORITHM forecast_savings_and_investments(db, user_id, projection_months=12)
|
| 851 |
+
INPUT: db β Session, user_id β string, projection_months β int
|
| 852 |
+
OUTPUT: dict with savings_growth, investment_growth, debt_decline arrays
|
| 853 |
+
|
| 854 |
+
BEGIN
|
| 855 |
+
savings_apr β 0.04 // 4% APY
|
| 856 |
+
investment_apr β 0.08 // 8% APY
|
| 857 |
+
|
| 858 |
+
(_, daily_income, daily_spending) β get_cashflow_metrics(db, user_id)
|
| 859 |
+
net_monthly β MAX(0.0, (daily_income - daily_spending) * 30.4)
|
| 860 |
+
monthly_savings_addition β net_monthly * 0.5
|
| 861 |
+
monthly_investment_addition β net_monthly * 0.3
|
| 862 |
+
monthly_debt_payment β MAX(150.0, net_monthly * 0.1)
|
| 863 |
+
|
| 864 |
+
current_savings β savings_balance
|
| 865 |
+
current_inv β total_invested
|
| 866 |
+
total_debt β debt_from_goals OR 5000.0
|
| 867 |
+
|
| 868 |
+
FOR month β 0 TO projection_months DO
|
| 869 |
+
IF month > 0 THEN
|
| 870 |
+
// Compound interest with monthly additions
|
| 871 |
+
current_savings β (current_savings + monthly_savings_addition) * (1 + savings_apr / 12)
|
| 872 |
+
current_inv β (current_inv + monthly_investment_addition) * (1 + investment_apr / 12)
|
| 873 |
+
total_debt β MAX(0.0, total_debt - monthly_debt_payment)
|
| 874 |
+
END IF
|
| 875 |
+
|
| 876 |
+
ASSERT current_savings >= 0.0
|
| 877 |
+
ASSERT current_inv >= 0.0
|
| 878 |
+
ASSERT total_debt >= 0.0
|
| 879 |
+
|
| 880 |
+
savings_data.APPEND({month: "Month N", amount: ROUND(current_savings, 2)})
|
| 881 |
+
investment_data.APPEND({month: "Month N", amount: ROUND(current_inv, 2)})
|
| 882 |
+
debt_data.APPEND({month: "Month N", amount: ROUND(total_debt, 2)})
|
| 883 |
+
END FOR
|
| 884 |
+
|
| 885 |
+
RETURN {savings_growth, investment_growth, debt_decline, ...summary_fields}
|
| 886 |
+
END
|
| 887 |
+
```
|
| 888 |
+
|
| 889 |
+
**Loop Invariant:** At each iteration, `current_savings >= 0`, `current_inv >= 0`, `total_debt >= 0`
|
| 890 |
+
|
| 891 |
+
**Postconditions:**
|
| 892 |
+
- All three arrays have exactly `projection_months + 1` entries
|
| 893 |
+
- `debt_decline` is monotonically non-increasing
|
| 894 |
+
- `savings_growth` and `investment_growth` are monotonically non-decreasing (given positive net monthly)
|
| 895 |
+
|
| 896 |
+
---
|
| 897 |
+
|
| 898 |
+
## Key Functions with Formal Specifications
|
| 899 |
+
|
| 900 |
+
### get_cashflow_metrics
|
| 901 |
+
|
| 902 |
+
```python
|
| 903 |
+
def get_cashflow_metrics(db: Session, user_id: str, days: int = 90
|
| 904 |
+
) -> tuple[float, float, float]:
|
| 905 |
+
```
|
| 906 |
+
|
| 907 |
+
**Preconditions:**
|
| 908 |
+
- `days > 0`
|
| 909 |
+
- `user_id` is a valid string (may reference a user with no accounts)
|
| 910 |
+
|
| 911 |
+
**Postconditions:**
|
| 912 |
+
- Returns `(current_balance, avg_daily_income, avg_daily_spending)` β all `>= 0.0`
|
| 913 |
+
- If no accounts exist: returns `(0.0, 0.0, 0.0)`
|
| 914 |
+
- If no transactions in window: returns `(current_balance, 0.0, 0.0)`
|
| 915 |
+
- `avg_daily_income = total_credits_in_window / days`
|
| 916 |
+
- `avg_daily_spending = total_debits_in_window / days`
|
| 917 |
+
|
| 918 |
+
---
|
| 919 |
+
|
| 920 |
+
### simulate_purchase_impact
|
| 921 |
+
|
| 922 |
+
```python
|
| 923 |
+
def simulate_purchase_impact(
|
| 924 |
+
db: Session, user_id: str, amount: float, category: str, merchant: str
|
| 925 |
+
) -> dict:
|
| 926 |
+
```
|
| 927 |
+
|
| 928 |
+
**Preconditions:**
|
| 929 |
+
- `amount > 0.0`
|
| 930 |
+
- `category` and `merchant` are non-empty strings
|
| 931 |
+
|
| 932 |
+
**Postconditions:**
|
| 933 |
+
- `projected_balance = MAX(0.0, total_balance - amount)`
|
| 934 |
+
- `risk_level` β `{"low", "medium", "high", "critical"}`
|
| 935 |
+
- `emergency_buffer_breached = (total_balance - amount) < emergency_threshold`
|
| 936 |
+
- `recommendation` is a non-empty string
|
| 937 |
+
|
| 938 |
+
---
|
| 939 |
+
|
| 940 |
+
### simulate_investment_impact
|
| 941 |
+
|
| 942 |
+
```python
|
| 943 |
+
def simulate_investment_impact(
|
| 944 |
+
db: Session, user_id: str, monthly_sip: float, asset_type: str, lump_sum: float = 0.0
|
| 945 |
+
) -> dict:
|
| 946 |
+
```
|
| 947 |
+
|
| 948 |
+
**Preconditions:**
|
| 949 |
+
- `monthly_sip >= 0.0`
|
| 950 |
+
- `lump_sum >= 0.0`
|
| 951 |
+
- `asset_type` β `{"stock", "crypto", "mutual_fund", "fd", "bond"}` (defaults to 7% APR for unknown)
|
| 952 |
+
|
| 953 |
+
**Postconditions:**
|
| 954 |
+
- `growth_projection` contains exactly 3 entries (year 1, 3, 5)
|
| 955 |
+
- For each entry: `future_value >= total_invested` (compound growth is non-negative)
|
| 956 |
+
- `is_affordable = (monthly_net >= monthly_sip)`
|
| 957 |
+
|
| 958 |
+
---
|
| 959 |
+
|
| 960 |
+
### stream_chat_response
|
| 961 |
+
|
| 962 |
+
```python
|
| 963 |
+
def stream_chat_response(db: Session, user_id: str, prompt: str
|
| 964 |
+
) -> Generator[str, None, None]:
|
| 965 |
+
```
|
| 966 |
+
|
| 967 |
+
**Preconditions:**
|
| 968 |
+
- `prompt` is a non-empty string
|
| 969 |
+
- `user_id` references an existing user (or fallback user is used)
|
| 970 |
+
|
| 971 |
+
**Postconditions:**
|
| 972 |
+
- Yields at least one non-empty string chunk
|
| 973 |
+
- After all chunks are yielded, `chat_memory` contains the user message and assembled assistant reply
|
| 974 |
+
- History is capped at 12 messages (6 conversation rounds)
|
| 975 |
+
- Never raises an exception to the caller β all backend errors are caught and a fallback chunk is yielded
|
| 976 |
+
|
| 977 |
+
---
|
| 978 |
+
|
| 979 |
+
### WebSocketConnectionManager.connect
|
| 980 |
+
|
| 981 |
+
```python
|
| 982 |
+
async def connect(self, websocket: WebSocket, user_id: str) -> None:
|
| 983 |
+
```
|
| 984 |
+
|
| 985 |
+
**Preconditions:**
|
| 986 |
+
- `websocket` is an unaccepted FastAPI WebSocket instance
|
| 987 |
+
- `user_id` is a non-empty string
|
| 988 |
+
|
| 989 |
+
**Postconditions:**
|
| 990 |
+
- `websocket.accept()` has been called
|
| 991 |
+
- `user_id` key exists in `self.active_connections`
|
| 992 |
+
- `websocket` is present in `self.active_connections[user_id]`
|
| 993 |
+
- Multiple connections per user are supported (list, not single slot)
|
| 994 |
+
|
| 995 |
+
---
|
| 996 |
+
|
| 997 |
+
## Example Usage
|
| 998 |
+
|
| 999 |
+
### 1. Balance Prediction (HTTP)
|
| 1000 |
+
|
| 1001 |
+
```python
|
| 1002 |
+
import httpx
|
| 1003 |
+
|
| 1004 |
+
# Get 90-day balance projection for the first user in DB
|
| 1005 |
+
response = httpx.get("http://localhost:8000/api/ai/twin/predict")
|
| 1006 |
+
data = response.json()
|
| 1007 |
+
|
| 1008 |
+
# Expected shape:
|
| 1009 |
+
# {
|
| 1010 |
+
# "current_balance": 12500.00,
|
| 1011 |
+
# "projected_balance": 14200.00,
|
| 1012 |
+
# "percent_change": 13.6,
|
| 1013 |
+
# "net_daily": 18.89,
|
| 1014 |
+
# "insight": "Based on current trends, your total balance is projected to grow...",
|
| 1015 |
+
# "chart_data": [{"date": "2025-05-24", "balance": 12500.00}, ...]
|
| 1016 |
+
# }
|
| 1017 |
+
print(data["insight"])
|
| 1018 |
+
```
|
| 1019 |
+
|
| 1020 |
+
### 2. Purchase Simulation (HTTP POST)
|
| 1021 |
+
|
| 1022 |
+
```python
|
| 1023 |
+
response = httpx.post(
|
| 1024 |
+
"http://localhost:8000/api/ai/simulate/purchase",
|
| 1025 |
+
json={"amount": 3500.0, "merchant": "Tesla Dealership", "category": "Transport"}
|
| 1026 |
+
)
|
| 1027 |
+
data = response.json()
|
| 1028 |
+
|
| 1029 |
+
# Expected shape:
|
| 1030 |
+
# {
|
| 1031 |
+
# "risk_analysis": {"risk_level": "high", "reasons": [...]},
|
| 1032 |
+
# "projected_balance": 9000.00,
|
| 1033 |
+
# "recommendation": "β οΈ Refrain from this purchase if possible..."
|
| 1034 |
+
# }
|
| 1035 |
+
```
|
| 1036 |
+
|
| 1037 |
+
### 3. Financial Health Score (HTTP)
|
| 1038 |
+
|
| 1039 |
+
```python
|
| 1040 |
+
response = httpx.get("http://localhost:8000/api/ai/coaching/score")
|
| 1041 |
+
data = response.json()
|
| 1042 |
+
|
| 1043 |
+
# Expected shape:
|
| 1044 |
+
# {
|
| 1045 |
+
# "overall_score": 72.0,
|
| 1046 |
+
# "categories": {
|
| 1047 |
+
# "savings_consistency": {"score": 15.0, "max": 20},
|
| 1048 |
+
# "debt_ratio": {"score": 18.0, "max": 20},
|
| 1049 |
+
# ...
|
| 1050 |
+
# },
|
| 1051 |
+
# "explanation": "As a Saver, your financial health score of 72 reflects...",
|
| 1052 |
+
# "actionable_improvements": ["Build savings buffer...", ...]
|
| 1053 |
+
# }
|
| 1054 |
+
```
|
| 1055 |
+
|
| 1056 |
+
### 4. WebSocket Streaming Chat
|
| 1057 |
+
|
| 1058 |
+
```python
|
| 1059 |
+
import asyncio
|
| 1060 |
+
import websockets
|
| 1061 |
+
import json
|
| 1062 |
+
|
| 1063 |
+
async def chat():
|
| 1064 |
+
uri = "ws://localhost:8000/api/ai/chat/ws"
|
| 1065 |
+
async with websockets.connect(uri) as ws:
|
| 1066 |
+
await ws.send(json.dumps({"type": "chat", "message": "What is my savings rate?"}))
|
| 1067 |
+
|
| 1068 |
+
full_reply = ""
|
| 1069 |
+
async for raw in ws:
|
| 1070 |
+
msg = json.loads(raw)
|
| 1071 |
+
if msg["type"] == "chat_chunk":
|
| 1072 |
+
full_reply += msg["content"]
|
| 1073 |
+
print(msg["content"], end="", flush=True)
|
| 1074 |
+
elif msg["type"] == "chat_end":
|
| 1075 |
+
break
|
| 1076 |
+
|
| 1077 |
+
asyncio.run(chat())
|
| 1078 |
+
```
|
| 1079 |
+
|
| 1080 |
+
### 5. Fraud Evaluation (HTTP POST)
|
| 1081 |
+
|
| 1082 |
+
```python
|
| 1083 |
+
# Evaluate a specific transaction
|
| 1084 |
+
response = httpx.post(
|
| 1085 |
+
"http://localhost:8000/api/ai/fraud/evaluate/some-transaction-uuid"
|
| 1086 |
+
)
|
| 1087 |
+
data = response.json()
|
| 1088 |
+
|
| 1089 |
+
# Expected shape:
|
| 1090 |
+
# {
|
| 1091 |
+
# "fraud_risk_score": 65,
|
| 1092 |
+
# "is_anomalous": true,
|
| 1093 |
+
# "status": "flagged",
|
| 1094 |
+
# "explanations": ["Amount is 3.5x historical average", "Unusual timing: 11PMβ4AM"]
|
| 1095 |
+
# }
|
| 1096 |
+
```
|
| 1097 |
+
|
| 1098 |
+
### 6. Subscription Optimization (HTTP)
|
| 1099 |
+
|
| 1100 |
+
```python
|
| 1101 |
+
response = httpx.get("http://localhost:8000/api/ai/subscriptions/optimize")
|
| 1102 |
+
data = response.json()
|
| 1103 |
+
|
| 1104 |
+
# Expected shape:
|
| 1105 |
+
# {
|
| 1106 |
+
# "subscriptions": [...],
|
| 1107 |
+
# "duplicates": [{"merchant": "Netflix", "count": 2, ...}],
|
| 1108 |
+
# "unused_subscriptions": [{"merchant": "Gym", "yearly_savings": 240.0, ...}],
|
| 1109 |
+
# "yearly_savings_potential": 240.0,
|
| 1110 |
+
# "risk_analysis": [...]
|
| 1111 |
+
# }
|
| 1112 |
+
```
|
| 1113 |
+
|
| 1114 |
+
---
|
| 1115 |
+
|
| 1116 |
+
## Correctness Properties
|
| 1117 |
+
|
| 1118 |
+
The following properties must hold universally across all valid inputs:
|
| 1119 |
+
|
| 1120 |
+
Property 1: Balance non-negativity β For all users and projection windows,
|
| 1121 |
+
`projected_balance >= 0.0`. Balance is floored at zero; net negative cashflow
|
| 1122 |
+
cannot produce a negative projected balance.
|
| 1123 |
+
|
| 1124 |
+
**Validates: Requirements 1.1**
|
| 1125 |
+
|
| 1126 |
+
Property 2: Score boundedness β For all users,
|
| 1127 |
+
`0.0 <= overall_financial_health_score <= 100.0`. The score is clamped after
|
| 1128 |
+
summing all six sub-scores.
|
| 1129 |
+
|
| 1130 |
+
**Validates: Requirements 1.2**
|
| 1131 |
+
|
| 1132 |
+
Property 3: Score sub-score consistency β The sum of all six sub-scores equals
|
| 1133 |
+
`overall_score` before clamping. Each sub-score is non-negative and within its
|
| 1134 |
+
declared maximum (20, 20, 20, 20, 10, 10).
|
| 1135 |
+
|
| 1136 |
+
**Validates: Requirements 1.2**
|
| 1137 |
+
|
| 1138 |
+
Property 4: Fraud score boundedness β For all transactions,
|
| 1139 |
+
`0 <= fraud_risk_score <= 100`. The score is capped with `min(100, score)` after
|
| 1140 |
+
all rules are applied.
|
| 1141 |
+
|
| 1142 |
+
**Validates: Requirements 1.3**
|
| 1143 |
+
|
| 1144 |
+
Property 5: Fraud log idempotency β Evaluating the same transaction twice does not
|
| 1145 |
+
create duplicate `FraudLog` rows. The second call is a no-op if a log already exists
|
| 1146 |
+
for that `transaction_id`.
|
| 1147 |
+
|
| 1148 |
+
**Validates: Requirements 1.3**
|
| 1149 |
+
|
| 1150 |
+
Property 6: Cache transparency β For any endpoint with caching, the response returned
|
| 1151 |
+
from cache is semantically identical to a freshly computed response for the same
|
| 1152 |
+
`user_id` and parameters within the TTL window.
|
| 1153 |
+
|
| 1154 |
+
**Validates: Requirements 1.4**
|
| 1155 |
+
|
| 1156 |
+
Property 7: Chat memory cap β For any user,
|
| 1157 |
+
`len(chat_memory.get_history(user_id)) <= 12` at all times. History is trimmed to
|
| 1158 |
+
the last 12 messages after every addition.
|
| 1159 |
+
|
| 1160 |
+
**Validates: Requirements 1.5**
|
| 1161 |
+
|
| 1162 |
+
Property 8: Streaming completeness β Every `chat_start` WebSocket event is followed
|
| 1163 |
+
by zero or more `chat_chunk` events and exactly one `chat_end` event per request,
|
| 1164 |
+
regardless of which AI backend handles the response.
|
| 1165 |
+
|
| 1166 |
+
**Validates: Requirements 1.5**
|
| 1167 |
+
|
| 1168 |
+
Property 9: Insights non-empty β `analyze_spending_behavior` always returns at least
|
| 1169 |
+
one insight string. When no anomalies are detected, a stable-spending confirmation
|
| 1170 |
+
message is appended as the default.
|
| 1171 |
+
|
| 1172 |
+
**Validates: Requirements 1.6**
|
| 1173 |
+
|
| 1174 |
+
Property 10: Compound growth monotonicity β In `forecast_savings_and_investments`,
|
| 1175 |
+
given `net_monthly > 0`, the `savings_growth` and `investment_growth` arrays are
|
| 1176 |
+
monotonically non-decreasing across all months.
|
| 1177 |
+
|
| 1178 |
+
**Validates: Requirements 1.1**
|
| 1179 |
+
|
| 1180 |
+
Property 11: Debt decline monotonicity β The `debt_decline` array is monotonically
|
| 1181 |
+
non-increasing. Debt is reduced by `monthly_debt_payment` each month and floored at
|
| 1182 |
+
zero; it never increases in the projection model.
|
| 1183 |
+
|
| 1184 |
+
**Validates: Requirements 1.1**
|
| 1185 |
+
|
| 1186 |
+
Property 12: DB fallback transparency β All AI engine functions behave identically
|
| 1187 |
+
whether the underlying engine is PostgreSQL or SQLite. No engine-specific SQL syntax
|
| 1188 |
+
is used; all queries go through the SQLAlchemy ORM.
|
| 1189 |
+
|
| 1190 |
+
**Validates: Requirements 1.7**
|
| 1191 |
+
|
| 1192 |
+
Property 13: AI backend fallback completeness β `get_chat_response` and
|
| 1193 |
+
`stream_chat_response` always return a non-empty response regardless of which AI
|
| 1194 |
+
backends are available, including when all external backends are offline (the
|
| 1195 |
+
offline rule-based fallback is invoked).
|
| 1196 |
+
|
| 1197 |
+
**Validates: Requirements 1.8**
|
| 1198 |
+
|
| 1199 |
+
Property 14: WebSocket multi-connection β A single `user_id` can have multiple
|
| 1200 |
+
simultaneous WebSocket connections. `send_personal_message` delivers the message
|
| 1201 |
+
to all active connections for that user.
|
| 1202 |
+
|
| 1203 |
+
**Validates: Requirements 1.5**
|
| 1204 |
+
|
| 1205 |
+
---
|
| 1206 |
+
|
| 1207 |
+
## Error Handling
|
| 1208 |
+
|
| 1209 |
+
### Scenario 1: PostgreSQL Unavailable
|
| 1210 |
+
|
| 1211 |
+
**Condition**: `OperationalError` raised during engine connection test at startup.
|
| 1212 |
+
**Response**: `database.py` catches the exception, logs a warning, and re-initializes
|
| 1213 |
+
the engine with `sqlite:///./bankbot.db` and `check_same_thread=False`.
|
| 1214 |
+
**Recovery**: All subsequent DB operations use SQLite transparently. No restart required.
|
| 1215 |
+
|
| 1216 |
+
---
|
| 1217 |
+
|
| 1218 |
+
### Scenario 2: Redis Unavailable
|
| 1219 |
+
|
| 1220 |
+
**Condition**: `redis.Redis.ping()` raises an exception or `redis` library is not installed.
|
| 1221 |
+
**Response**: `CacheManager.__init__` catches the exception, sets `use_redis = False`,
|
| 1222 |
+
and all cache operations route to `MemoryCache`.
|
| 1223 |
+
**Recovery**: In-memory cache is thread-safe via `threading.Lock`. TTL eviction is
|
| 1224 |
+
lazy (checked on `get`). Cache is lost on process restart (acceptable for dev/staging).
|
| 1225 |
+
|
| 1226 |
+
---
|
| 1227 |
+
|
| 1228 |
+
### Scenario 3: All AI Backends Offline
|
| 1229 |
+
|
| 1230 |
+
**Condition**: `OPENAI_API_KEY` and `GROQ_API_KEY` are unset; local Ollama is unreachable.
|
| 1231 |
+
**Response**: `has_active_ai_backend()` returns `False`. `get_chat_response` and
|
| 1232 |
+
`generate_daily_briefing` invoke `get_offline_chat_fallback()` which generates a
|
| 1233 |
+
rule-based, data-grounded response from the user's DB records.
|
| 1234 |
+
**Recovery**: Responses are still financially meaningful (use real scores and balances).
|
| 1235 |
+
No exception is surfaced to the API caller.
|
| 1236 |
+
|
| 1237 |
+
---
|
| 1238 |
+
|
| 1239 |
+
### Scenario 4: User Not Found
|
| 1240 |
+
|
| 1241 |
+
**Condition**: `user_id` query param is absent or references a non-existent user.
|
| 1242 |
+
**Response**: `get_user_id_fallback()` in `router.py` queries the first available user.
|
| 1243 |
+
If no users exist, raises `HTTPException(404, "No users found. Seed the database first.")`.
|
| 1244 |
+
**Recovery**: Run `python backend/app/scripts/seed.py` to populate the database.
|
| 1245 |
+
|
| 1246 |
+
---
|
| 1247 |
+
|
| 1248 |
+
### Scenario 5: WebSocket Client Disconnects Unexpectedly
|
| 1249 |
+
|
| 1250 |
+
**Condition**: `WebSocketDisconnect` raised during `websocket.receive_text()`.
|
| 1251 |
+
**Response**: `websocket/router.py` catches `WebSocketDisconnect`, calls
|
| 1252 |
+
`ws_manager.disconnect(websocket, user_id)`, and closes the DB session in `finally`.
|
| 1253 |
+
**Recovery**: Client can reconnect; server state is clean. Chat history is preserved
|
| 1254 |
+
in `ChatMemoryManager` for the session duration.
|
| 1255 |
+
|
| 1256 |
+
---
|
| 1257 |
+
|
| 1258 |
+
### Scenario 6: Transaction Not Found for Fraud Evaluation
|
| 1259 |
+
|
| 1260 |
+
**Condition**: `POST /api/ai/fraud/evaluate/{transaction_id}` with a non-existent ID.
|
| 1261 |
+
**Response**: `evaluate_transaction_for_fraud` returns `{"error": "Transaction not found"}`.
|
| 1262 |
+
**Recovery**: Caller should verify the transaction ID before calling the endpoint.
|
| 1263 |
+
|
| 1264 |
+
---
|
| 1265 |
+
|
| 1266 |
+
### Scenario 7: OpenAI / Groq API Error During Streaming
|
| 1267 |
+
|
| 1268 |
+
**Condition**: Network error or rate limit during `stream_chat_response`.
|
| 1269 |
+
**Response**: The exception is caught per-backend; the next backend in the fallback
|
| 1270 |
+
chain is attempted. If all fail, `get_offline_chat_fallback()` result is yielded as
|
| 1271 |
+
a single chunk.
|
| 1272 |
+
**Recovery**: Streaming continues without interruption from the client's perspective.
|
| 1273 |
+
|
| 1274 |
+
---
|
| 1275 |
+
|
| 1276 |
+
## Testing Strategy
|
| 1277 |
+
|
| 1278 |
+
### Unit Testing Approach
|
| 1279 |
+
|
| 1280 |
+
Each AI engine module is tested in isolation with a mocked SQLAlchemy session.
|
| 1281 |
+
Key test cases per module:
|
| 1282 |
+
|
| 1283 |
+
- **forecasting.py**: Zero-transaction user returns `(balance, 0.0, 0.0)`;
|
| 1284 |
+
negative net daily floors projected balance at 0.0; chart_data length matches
|
| 1285 |
+
`ceil(projection_days / 5) + 1`.
|
| 1286 |
+
- **coaching.py**: Score is always in `[0, 100]`; improvements list is non-empty;
|
| 1287 |
+
all six sub-scores are within their declared maximums.
|
| 1288 |
+
- **fraud.py**: Score caps at 100 even when multiple rules fire simultaneously;
|
| 1289 |
+
duplicate FraudLog is not created on second evaluation of same transaction.
|
| 1290 |
+
- **behavior.py**: Returns at least one insight for any input including empty transaction list.
|
| 1291 |
+
- **simulation.py**: `projected_balance >= 0` for any purchase amount; investment
|
| 1292 |
+
growth projection always has exactly 3 entries.
|
| 1293 |
+
- **cache.py**: MemoryCache TTL eviction works correctly; expired keys return `None`.
|
| 1294 |
+
|
| 1295 |
+
### Property-Based Testing Approach
|
| 1296 |
+
|
| 1297 |
+
**Property Test Library**: `hypothesis` (Python)
|
| 1298 |
+
|
| 1299 |
+
Key properties to test with generated inputs:
|
| 1300 |
+
|
| 1301 |
+
```python
|
| 1302 |
+
from hypothesis import given, strategies as st
|
| 1303 |
+
|
| 1304 |
+
@given(st.floats(min_value=0, max_value=1e9), st.floats(min_value=0, max_value=1e6))
|
| 1305 |
+
def test_projected_balance_non_negative(current_balance, net_daily_loss):
|
| 1306 |
+
"""projected_balance is always >= 0 regardless of net daily cashflow."""
|
| 1307 |
+
result = max(0.0, current_balance - net_daily_loss * 90)
|
| 1308 |
+
assert result >= 0.0
|
| 1309 |
+
|
| 1310 |
+
@given(st.floats(min_value=0, max_value=1e6), st.floats(min_value=0, max_value=1e6),
|
| 1311 |
+
st.floats(min_value=0, max_value=1e6), st.floats(min_value=0, max_value=1e6),
|
| 1312 |
+
st.floats(min_value=0, max_value=1e6), st.floats(min_value=0, max_value=1e6))
|
| 1313 |
+
def test_health_score_bounded(s1, s2, s3, s4, s5, s6):
|
| 1314 |
+
"""Financial health score is always in [0, 100]."""
|
| 1315 |
+
raw = s1 + s2 + s3 + s4 + s5 + s6
|
| 1316 |
+
score = min(100.0, max(0.0, raw))
|
| 1317 |
+
assert 0.0 <= score <= 100.0
|
| 1318 |
+
|
| 1319 |
+
@given(st.integers(min_value=0, max_value=500))
|
| 1320 |
+
def test_fraud_score_bounded(rule_score_sum):
|
| 1321 |
+
"""Fraud score is always capped at 100."""
|
| 1322 |
+
score = min(100, rule_score_sum)
|
| 1323 |
+
assert 0 <= score <= 100
|
| 1324 |
+
```
|
| 1325 |
+
|
| 1326 |
+
### Integration Testing Approach
|
| 1327 |
+
|
| 1328 |
+
1. **Database Fallback**: Start with `DATABASE_URL` pointing to an unreachable host;
|
| 1329 |
+
verify `engine` uses SQLite and `Base.metadata.create_all()` succeeds.
|
| 1330 |
+
2. **Seed + Endpoint Round-trip**: Run `seed.py`, then call all GET endpoints via
|
| 1331 |
+
`httpx`; assert all return HTTP 200 with non-empty JSON bodies.
|
| 1332 |
+
3. **WebSocket Chat**: Open a WebSocket connection, send a chat message, collect all
|
| 1333 |
+
chunks until `chat_end`, assert the assembled reply is a non-empty string.
|
| 1334 |
+
4. **Cache Hit Verification**: Call a cached endpoint twice; assert the second call
|
| 1335 |
+
returns within 10ms (cache hit) and the response is identical.
|
| 1336 |
+
5. **Fraud Idempotency**: Call `POST /api/ai/fraud/evaluate/{id}` twice for the same
|
| 1337 |
+
transaction; assert only one `FraudLog` row exists in the DB.
|
| 1338 |
+
|
| 1339 |
+
---
|
| 1340 |
+
|
| 1341 |
+
## Performance Considerations
|
| 1342 |
+
|
| 1343 |
+
- **Cache TTLs are tuned by endpoint cost**: Briefings (LLM-heavy) cache for 3600s;
|
| 1344 |
+
behavioral insights cache for 600s; balance projections cache for 300s.
|
| 1345 |
+
- **Chat history is capped at 12 messages** to bound the token count sent to LLMs
|
| 1346 |
+
and prevent context window overflow.
|
| 1347 |
+
- **AI backend detection is done once at module load time** (`AI_BACKEND_AVAILABLE`
|
| 1348 |
+
flag in `ollama_integration.py`) to avoid per-request timeout delays.
|
| 1349 |
+
- **Fraud history window is capped at 30 transactions** to keep anomaly detection O(1)
|
| 1350 |
+
in practice.
|
| 1351 |
+
- **WebSocket streaming** avoids buffering the full LLM response before delivery,
|
| 1352 |
+
reducing perceived latency for the user.
|
| 1353 |
+
- **SQLite WAL mode** should be enabled for concurrent read performance if multiple
|
| 1354 |
+
workers are used with the SQLite fallback.
|
| 1355 |
+
|
| 1356 |
+
---
|
| 1357 |
+
|
| 1358 |
+
## Security Considerations
|
| 1359 |
+
|
| 1360 |
+
- **No authentication on AI endpoints** in the current implementation β `user_id` is
|
| 1361 |
+
passed as a query parameter. Production deployment must add JWT middleware to
|
| 1362 |
+
validate that the requesting user can only access their own data.
|
| 1363 |
+
- **API keys** (`OPENAI_API_KEY`, `GROQ_API_KEY`) are read from environment variables
|
| 1364 |
+
and never logged or returned in API responses.
|
| 1365 |
+
- **SQL injection** is not possible β all DB queries use SQLAlchemy ORM with
|
| 1366 |
+
parameterized bindings.
|
| 1367 |
+
- **WebSocket origin validation** is not enforced in development. Production should
|
| 1368 |
+
restrict `allow_origins` in CORS middleware and validate WebSocket upgrade headers.
|
| 1369 |
+
- **Fraud log writes** are the only DB mutations in the AI layer; all other operations
|
| 1370 |
+
are read-only, limiting the blast radius of any AI module bug.
|
| 1371 |
+
- **LLM prompt injection**: User chat messages are passed as `role: user` content,
|
| 1372 |
+
not interpolated into the system prompt, reducing prompt injection risk.
|
| 1373 |
+
|
| 1374 |
+
---
|
| 1375 |
+
|
| 1376 |
+
## Dependencies
|
| 1377 |
+
|
| 1378 |
+
| Package | Purpose | Notes |
|
| 1379 |
+
|---------|---------|-------|
|
| 1380 |
+
| `fastapi` | HTTP and WebSocket framework | Core API layer |
|
| 1381 |
+
| `uvicorn` | ASGI server | Run with `uvicorn app.main:app` |
|
| 1382 |
+
| `sqlalchemy` | ORM and DB abstraction | PostgreSQL + SQLite |
|
| 1383 |
+
| `psycopg2-binary` | PostgreSQL driver | Falls back gracefully if PG unavailable |
|
| 1384 |
+
| `openai` | OpenAI API client | `gpt-4o-mini` default model |
|
| 1385 |
+
| `groq` | Groq API client | `llama-3.3-70b-versatile` fallback |
|
| 1386 |
+
| `requests` | HTTP client for Ollama | Local Ollama REST API |
|
| 1387 |
+
| `redis` | Redis client | Optional; falls back to MemoryCache |
|
| 1388 |
+
| `numpy` | Statistical computations | Mean, stddev for fraud/behavior |
|
| 1389 |
+
| `langchain` | LLM orchestration | Listed in requirements; available for future chain-based features |
|
| 1390 |
+
| `pydantic` | Request/response validation | Pydantic v2 compatible |
|
| 1391 |
+
| `hypothesis` | Property-based testing | Dev dependency |
|
| 1392 |
+
| `httpx` | Async HTTP client for tests | Dev dependency |
|
| 1393 |
+
| `websockets` | WebSocket client for tests | Dev dependency |
|
.kiro/specs/bankbot-ai-intelligence/tasks.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tasks: BankBot AI Intelligence & API (Phase 4)
|
| 2 |
+
|
| 3 |
+
## Implementation Status Summary
|
| 4 |
+
|
| 5 |
+
After a full codebase audit, the majority of Phase 4 is already implemented. The tasks below
|
| 6 |
+
reflect the **actual gaps** between the current code and the design specification β not a
|
| 7 |
+
from-scratch build. Each task is scoped to what genuinely needs to be done.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Task 1: Fix requirements.txt β Add Missing Dependencies
|
| 12 |
+
|
| 13 |
+
**Status**: β
Complete
|
| 14 |
+
**Priority**: Critical (blocks everything else)
|
| 15 |
+
**File**: `backend/requirements.txt`
|
| 16 |
+
|
| 17 |
+
The current `requirements.txt` is missing several packages that the AI layer actively imports.
|
| 18 |
+
Running the backend without these will cause `ImportError` crashes.
|
| 19 |
+
|
| 20 |
+
- [ ] 1.1 Add `groq` β required by `ollama_integration.py` (`from groq import Groq`)
|
| 21 |
+
- [ ] 1.2 Add `redis` β required by `middleware/cache.py` (`import redis`)
|
| 22 |
+
- [ ] 1.3 Add `numpy` β required by `ai/fraud.py` and `ai/behavior.py` (`import numpy as np`)
|
| 23 |
+
- [ ] 1.4 Add `httpx` β required for endpoint validation scripts
|
| 24 |
+
- [ ] 1.5 Add `websockets` β required for WebSocket test script
|
| 25 |
+
- [ ] 1.6 Add `python-dotenv` β required to load `.env` file when running locally
|
| 26 |
+
- [ ] 1.7 Pin versions for all new additions (e.g. `groq==0.9.0`, `redis==5.0.4`, `numpy==1.26.4`)
|
| 27 |
+
|
| 28 |
+
**Acceptance**: `pip install -r requirements.txt` completes without errors in a clean venv.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Task 2: Extend ollama_integration.py β Add OpenAI to Unified Wrapper
|
| 33 |
+
|
| 34 |
+
**Status**: β
Complete
|
| 35 |
+
**Priority**: High
|
| 36 |
+
**File**: `backend/app/ai/ollama_integration.py`
|
| 37 |
+
|
| 38 |
+
The `get_ai_response()` and `stream_ai_response()` unified wrappers currently only route to
|
| 39 |
+
Groq or Ollama. The design specifies OpenAI as the **first priority** in the fallback chain.
|
| 40 |
+
`chat.py` and `coaching.py` implement OpenAI directly, but `ollama_integration.py` β which
|
| 41 |
+
is the shared backend abstraction β does not.
|
| 42 |
+
|
| 43 |
+
- [ ] 2.1 Add `get_openai_response(prompt, history, model, language)` function that calls
|
| 44 |
+
`openai.OpenAI(api_key=OPENAI_API_KEY).chat.completions.create(...)` with the configured
|
| 45 |
+
model (default `gpt-4o-mini`, read from `OPENAI_MODEL` env var)
|
| 46 |
+
- [ ] 2.2 Add `stream_openai_response(prompt, history, model, language)` generator that
|
| 47 |
+
calls the same endpoint with `stream=True` and yields content chunks
|
| 48 |
+
- [ ] 2.3 Update `get_ai_response()` to try OpenAI first, then Groq, then Ollama:
|
| 49 |
+
```python
|
| 50 |
+
if OPENAI_API_KEY:
|
| 51 |
+
result = get_openai_response(...)
|
| 52 |
+
if result: return result
|
| 53 |
+
if GROQ_API_KEY:
|
| 54 |
+
result = get_groq_response(...)
|
| 55 |
+
if result: return result
|
| 56 |
+
return get_ollama_response(...)
|
| 57 |
+
```
|
| 58 |
+
- [ ] 2.4 Update `stream_ai_response()` with the same three-tier priority order
|
| 59 |
+
- [ ] 2.5 Update `AI_BACKEND_AVAILABLE` detection at module load to also check `OPENAI_API_KEY`
|
| 60 |
+
(it already does β verify the logic is correct and add a comment)
|
| 61 |
+
- [ ] 2.6 Add `OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")` constant at
|
| 62 |
+
the top of the file so the model is configurable without code changes
|
| 63 |
+
|
| 64 |
+
**Acceptance**: With `OPENAI_API_KEY` set, `get_ai_response("test")` returns an OpenAI
|
| 65 |
+
response. With only `GROQ_API_KEY` set, it falls back to Groq. With neither, it falls back
|
| 66 |
+
to Ollama or returns `None`.
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Task 3: Update .env.example β Document All AI & Cache Variables
|
| 71 |
+
|
| 72 |
+
**Status**: β
Complete
|
| 73 |
+
**Priority**: High
|
| 74 |
+
**File**: `.env.example`
|
| 75 |
+
|
| 76 |
+
The `.env.example` needs to document every environment variable the Phase 4 AI layer reads,
|
| 77 |
+
so any developer can get the app running immediately.
|
| 78 |
+
|
| 79 |
+
- [ ] 3.1 Add `OPENAI_API_KEY=your_openai_key_here`
|
| 80 |
+
- [ ] 3.2 Add `OPENAI_MODEL=gpt-4o-mini`
|
| 81 |
+
- [ ] 3.3 Add `GROQ_API_KEY=your_groq_key_here`
|
| 82 |
+
- [ ] 3.4 Add `OLLAMA_MODEL=llama3:latest`
|
| 83 |
+
- [ ] 3.5 Add `REDIS_URL=redis://localhost:6379/0`
|
| 84 |
+
- [ ] 3.6 Add `USE_SQLITE=true` with a comment explaining it forces SQLite fallback
|
| 85 |
+
- [ ] 3.7 Add `DATABASE_URL=postgresql://admin:adminpassword@localhost:5432/bankbot`
|
| 86 |
+
- [ ] 3.8 Add a comment block at the top explaining the fallback priority chain:
|
| 87 |
+
`# AI: OpenAI β Groq β Ollama β offline fallback`
|
| 88 |
+
`# DB: PostgreSQL β SQLite`
|
| 89 |
+
`# Cache: Redis β in-memory TTL dict`
|
| 90 |
+
|
| 91 |
+
**Acceptance**: A developer can copy `.env.example` to `.env`, fill in one API key, and
|
| 92 |
+
the backend starts without any missing-variable errors.
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## Task 4: Validate and Harden seed.py
|
| 97 |
+
|
| 98 |
+
**Status**: β
Complete
|
| 99 |
+
**Priority**: Medium
|
| 100 |
+
**File**: `backend/app/scripts/seed.py`
|
| 101 |
+
|
| 102 |
+
The seed script works but has one logic bug: the `db.commit()` inside the transaction loop
|
| 103 |
+
commits after every single transaction, which is slow and can leave partial state on error.
|
| 104 |
+
Also, the script does not print which DB backend it seeded into.
|
| 105 |
+
|
| 106 |
+
- [ ] 4.1 Move `db.commit()` out of the per-transaction loop β commit once per user after
|
| 107 |
+
all their transactions, goals, investments, and subscriptions are added
|
| 108 |
+
- [ ] 4.2 Add a `try/except/rollback` block around the per-user seeding so a failure on
|
| 109 |
+
one user does not corrupt the others
|
| 110 |
+
- [ ] 4.3 Print the active database URL at the start of `seed_data()` so the developer
|
| 111 |
+
knows whether SQLite or PostgreSQL was used:
|
| 112 |
+
```python
|
| 113 |
+
from app.database.database import SQLALCHEMY_DATABASE_URL
|
| 114 |
+
print(f"Seeding into: {SQLALCHEMY_DATABASE_URL}")
|
| 115 |
+
```
|
| 116 |
+
- [ ] 4.4 Add a second subscription per user (e.g. Spotify at $9.99/month with
|
| 117 |
+
`usage_frequency: "low"`) so the subscription optimizer has data to detect unused subs
|
| 118 |
+
- [ ] 4.5 Add a duplicate subscription for one user (two Netflix entries) so the duplicate
|
| 119 |
+
detection logic in `subscriptions.py` has a real test case
|
| 120 |
+
- [ ] 4.6 Add a late-night transaction (timestamp hour = 23 or 0) for at least one user
|
| 121 |
+
so `behavior.py` late-night detection fires on seeded data
|
| 122 |
+
|
| 123 |
+
**Acceptance**: Running `python backend/app/scripts/seed.py` twice: first run seeds 5 users
|
| 124 |
+
and prints "Database seeded successfully!"; second run prints "Database already seeded." and
|
| 125 |
+
exits cleanly. The `subscriptions/optimize` endpoint returns at least one unused subscription
|
| 126 |
+
and one duplicate after seeding.
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## Task 5: Build test_endpoints.py β Full HTTP Validation Script
|
| 131 |
+
|
| 132 |
+
**Status**: β
Complete
|
| 133 |
+
**Priority**: High
|
| 134 |
+
**File**: `backend/app/scripts/test_endpoints.py`
|
| 135 |
+
|
| 136 |
+
The current `test_endpoints.py` needs to be a real validation script that calls every AI
|
| 137 |
+
endpoint and asserts the response shape is correct.
|
| 138 |
+
|
| 139 |
+
- [ ] 5.1 Import `httpx` and define `BASE_URL = "http://localhost:8000"`
|
| 140 |
+
- [ ] 5.2 Add a `get_first_user_id()` helper that calls `GET /api/ai/coaching/score` without
|
| 141 |
+
a `user_id` param (uses the fallback) and extracts the user from the response, or queries
|
| 142 |
+
the DB directly via `seed.py`'s session
|
| 143 |
+
- [ ] 5.3 Test `GET /api/ai/twin/predict` β assert `200`, assert keys
|
| 144 |
+
`current_balance`, `projected_balance`, `chart_data` exist, assert `len(chart_data) >= 1`
|
| 145 |
+
- [ ] 5.4 Test `GET /api/ai/twin/future` β assert `200`, assert `savings_growth` and
|
| 146 |
+
`investment_growth` are non-empty lists
|
| 147 |
+
- [ ] 5.5 Test `GET /api/ai/twin/scenarios` β assert `200`, assert keys `status_quo`,
|
| 148 |
+
`frugal`, `lifestyle_inflation` all present
|
| 149 |
+
- [ ] 5.6 Test `POST /api/ai/simulate/purchase` with body
|
| 150 |
+
`{"amount": 500.0, "merchant": "Test", "category": "Shopping"}` β assert `200`,
|
| 151 |
+
assert `risk_analysis.risk_level` is one of `low/medium/high/critical`
|
| 152 |
+
- [ ] 5.7 Test `POST /api/ai/simulate/investment` with body
|
| 153 |
+
`{"monthly_sip": 200.0, "asset_type": "stock"}` β assert `200`,
|
| 154 |
+
assert `growth_projection` has exactly 3 entries (year 1, 3, 5)
|
| 155 |
+
- [ ] 5.8 Test `GET /api/ai/behavior/insights` β assert `200`, assert `insights` is a
|
| 156 |
+
non-empty list
|
| 157 |
+
- [ ] 5.9 Test `GET /api/ai/coaching/score` β assert `200`, assert `overall_score` is
|
| 158 |
+
between 0 and 100, assert all 6 category keys are present
|
| 159 |
+
- [ ] 5.10 Test `GET /api/ai/coaching/briefing` β assert `200`, assert `briefing` is a
|
| 160 |
+
non-empty string
|
| 161 |
+
- [ ] 5.11 Test `GET /api/ai/subscriptions/optimize` β assert `200`, assert `subscriptions`
|
| 162 |
+
key exists
|
| 163 |
+
- [ ] 5.12 Test `GET /api/ai/fraud/analysis` β assert `200`, assert `total_alerts` key exists
|
| 164 |
+
- [ ] 5.13 Test `POST /api/ai/chat` with body `{"message": "What is my savings rate?"}` β
|
| 165 |
+
assert `200`, assert `response` is a non-empty string
|
| 166 |
+
- [ ] 5.14 Print a pass/fail summary table at the end showing each endpoint and its result
|
| 167 |
+
- [ ] 5.15 Exit with code `1` if any test fails so CI can detect failures
|
| 168 |
+
|
| 169 |
+
**Acceptance**: Running `python backend/app/scripts/test_endpoints.py` with the server
|
| 170 |
+
running prints a table where all 13 endpoints show PASS.
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## Task 6: Build test_websocket.py β WebSocket Streaming Validation Script
|
| 175 |
+
|
| 176 |
+
**Status**: β
Complete
|
| 177 |
+
**Priority**: High
|
| 178 |
+
**File**: `backend/app/scripts/test_websocket.py` (new file)
|
| 179 |
+
|
| 180 |
+
No WebSocket test script exists. This is required by the verification plan.
|
| 181 |
+
|
| 182 |
+
- [ ] 6.1 Create `backend/app/scripts/test_websocket.py`
|
| 183 |
+
- [ ] 6.2 Import `asyncio`, `websockets`, `json`
|
| 184 |
+
- [ ] 6.3 Write `async def test_chat_streaming()`:
|
| 185 |
+
- Connect to `ws://localhost:8000/api/ai/chat/ws`
|
| 186 |
+
- Send `{"type": "chat", "message": "What is my current balance?"}`
|
| 187 |
+
- Collect all messages until `type == "chat_end"`
|
| 188 |
+
- Assert at least one `chat_chunk` was received
|
| 189 |
+
- Assert the assembled reply is a non-empty string
|
| 190 |
+
- Print the full assembled reply
|
| 191 |
+
- [ ] 6.4 Write `async def test_ping_pong()`:
|
| 192 |
+
- Connect to the same endpoint
|
| 193 |
+
- Send `{"type": "ping"}`
|
| 194 |
+
- Assert the response is `{"type": "pong"}`
|
| 195 |
+
- [ ] 6.5 Write `async def test_invalid_json()`:
|
| 196 |
+
- Send a raw non-JSON string
|
| 197 |
+
- Assert the response contains `{"type": "error"}`
|
| 198 |
+
- [ ] 6.6 Run all three tests in `asyncio.run(main())` and print pass/fail for each
|
| 199 |
+
- [ ] 6.7 Exit with code `1` if any test fails
|
| 200 |
+
|
| 201 |
+
**Acceptance**: Running `python backend/app/scripts/test_websocket.py` with the server
|
| 202 |
+
running prints three PASS lines and exits with code 0.
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## Task 7: Add CORS Hardening and Health Endpoint to main.py
|
| 207 |
+
|
| 208 |
+
**Status**: β
Complete
|
| 209 |
+
**Priority**: Low (dev environment acceptable, note for production)
|
| 210 |
+
**File**: `backend/app/main.py`
|
| 211 |
+
|
| 212 |
+
- [ ] 7.1 Add a `GET /api/ai/status` endpoint that returns the active AI backend, DB type,
|
| 213 |
+
and cache type β useful for debugging without reading logs:
|
| 214 |
+
```python
|
| 215 |
+
@app.get("/api/ai/status")
|
| 216 |
+
def ai_status():
|
| 217 |
+
from app.ai.ollama_integration import has_active_ai_backend, OPENAI_API_KEY, GROQ_API_KEY
|
| 218 |
+
from app.middleware.cache import cache
|
| 219 |
+
from app.database.database import SQLALCHEMY_DATABASE_URL
|
| 220 |
+
return {
|
| 221 |
+
"ai_backend": "openai" if OPENAI_API_KEY else "groq" if GROQ_API_KEY else "ollama",
|
| 222 |
+
"ai_available": has_active_ai_backend(),
|
| 223 |
+
"db_type": "sqlite" if "sqlite" in SQLALCHEMY_DATABASE_URL else "postgresql",
|
| 224 |
+
"cache_type": "redis" if cache.use_redis else "memory"
|
| 225 |
+
}
|
| 226 |
+
```
|
| 227 |
+
- [ ] 7.2 Add a comment above `allow_origins=["*"]` noting it must be restricted to the
|
| 228 |
+
frontend origin in production (e.g. `["http://localhost:3000"]`)
|
| 229 |
+
|
| 230 |
+
**Acceptance**: `GET /api/ai/status` returns a JSON object with all four fields populated
|
| 231 |
+
correctly based on the active environment.
|
| 232 |
+
|
| 233 |
+
---
|
| 234 |
+
|
| 235 |
+
## Task 8: Verify Full Stack Runs End-to-End
|
| 236 |
+
|
| 237 |
+
**Status**: β
Complete β verified 2025-05-24
|
| 238 |
+
**Priority**: Critical
|
| 239 |
+
**This is the final integration verification task.**
|
| 240 |
+
|
| 241 |
+
- [ ] 8.1 Install dependencies: `pip install -r backend/requirements.txt`
|
| 242 |
+
- [ ] 8.2 Seed the database: `python backend/app/scripts/seed.py`
|
| 243 |
+
- Confirm output: `Seeding into: sqlite:///...` and `Database seeded successfully!`
|
| 244 |
+
- [ ] 8.3 Start the backend: `uvicorn app.main:app --reload` from `backend/`
|
| 245 |
+
- Confirm output: `Initializing database...` and `Database initialization complete.`
|
| 246 |
+
- Confirm no `ImportError` or `ModuleNotFoundError` in startup logs
|
| 247 |
+
- [ ] 8.4 Open Swagger UI at `http://localhost:8000/docs`
|
| 248 |
+
- Confirm all 13 AI endpoints appear under the `AI Intelligence` tag
|
| 249 |
+
- Confirm the WebSocket endpoint appears under `WebSockets`
|
| 250 |
+
- [ ] 8.5 Run HTTP validation: `python backend/app/scripts/test_endpoints.py`
|
| 251 |
+
- Confirm all 13 endpoints return PASS
|
| 252 |
+
- [ ] 8.6 Run WebSocket validation: `python backend/app/scripts/test_websocket.py`
|
| 253 |
+
- Confirm all 3 WebSocket tests return PASS
|
| 254 |
+
- [ ] 8.7 Check `GET /api/ai/status` returns correct backend/db/cache values
|
| 255 |
+
- [ ] 8.8 Manually call `GET /api/ai/coaching/score` via Swagger UI and confirm the
|
| 256 |
+
response contains `overall_score` between 0β100 and all 6 sub-score categories
|
| 257 |
+
|
| 258 |
+
**Acceptance**: All 8 sub-tasks complete without errors. The backend is fully operational
|
| 259 |
+
with SQLite fallback and in-memory cache, ready for frontend integration.
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## Dependency Map
|
| 264 |
+
|
| 265 |
+
```
|
| 266 |
+
Task 1 (requirements.txt)
|
| 267 |
+
βββ Task 2 (ollama_integration OpenAI)
|
| 268 |
+
βββ Task 5 (test_endpoints β needs httpx)
|
| 269 |
+
βββ Task 6 (test_websocket β needs websockets)
|
| 270 |
+
|
| 271 |
+
Task 3 (.env.example) β independent
|
| 272 |
+
|
| 273 |
+
Task 4 (seed.py hardening)
|
| 274 |
+
βββ Task 8.2 (seeding step in final verification)
|
| 275 |
+
|
| 276 |
+
Task 2 (ollama_integration)
|
| 277 |
+
βββ Task 8 (full stack verification)
|
| 278 |
+
|
| 279 |
+
Task 5 + Task 6 (test scripts)
|
| 280 |
+
βββ Task 8.5 + 8.6
|
| 281 |
+
|
| 282 |
+
Task 7 (status endpoint)
|
| 283 |
+
βββ Task 8.7
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
**Recommended execution order**: 1 β 3 β 4 β 2 β 7 β 5 β 6 β 8
|
.vscode/settings.json
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# BankBot AI β Hugging Face Spaces Dockerfile
|
| 3 |
+
#
|
| 4 |
+
# Single-container deployment:
|
| 5 |
+
# Port 7860 (HF requirement) β Nginx
|
| 6 |
+
# Nginx β Next.js (port 3000) for frontend
|
| 7 |
+
# Nginx β FastAPI (port 8000) for /api/* and /ws
|
| 8 |
+
#
|
| 9 |
+
# Build args:
|
| 10 |
+
# NEXT_PUBLIC_API_URL (default: relative /api proxy)
|
| 11 |
+
# ============================================================
|
| 12 |
+
|
| 13 |
+
# βββ Stage 1: Build Next.js frontend βββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
FROM node:20-alpine AS frontend-builder
|
| 15 |
+
|
| 16 |
+
WORKDIR /frontend
|
| 17 |
+
|
| 18 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 19 |
+
RUN npm ci --legacy-peer-deps --quiet
|
| 20 |
+
|
| 21 |
+
COPY frontend/ .
|
| 22 |
+
|
| 23 |
+
# In HF, frontend calls go through the same origin via Nginx proxy
|
| 24 |
+
# So NEXT_PUBLIC_API_URL is empty β rewrites handle /api/* internally
|
| 25 |
+
ARG NEXT_PUBLIC_API_URL=""
|
| 26 |
+
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
| 27 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 28 |
+
|
| 29 |
+
RUN npm run build
|
| 30 |
+
|
| 31 |
+
# βββ Stage 2: Python dependencies ββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
FROM python:3.11-slim AS python-builder
|
| 33 |
+
|
| 34 |
+
WORKDIR /build
|
| 35 |
+
|
| 36 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 37 |
+
build-essential \
|
| 38 |
+
libpq-dev \
|
| 39 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 40 |
+
|
| 41 |
+
COPY backend/requirements.txt .
|
| 42 |
+
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
| 43 |
+
|
| 44 |
+
# βββ Stage 3: Final runtime image ββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
FROM python:3.11-slim AS runtime
|
| 46 |
+
|
| 47 |
+
# Install Node.js, Nginx, supervisord, curl
|
| 48 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 49 |
+
nginx \
|
| 50 |
+
supervisor \
|
| 51 |
+
curl \
|
| 52 |
+
libpq5 \
|
| 53 |
+
ca-certificates \
|
| 54 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 55 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 56 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 57 |
+
|
| 58 |
+
# ββ Python packages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
COPY --from=python-builder /install /usr/local
|
| 60 |
+
|
| 61 |
+
# ββ Backend βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
WORKDIR /app/backend
|
| 63 |
+
COPY backend/app/ ./app/
|
| 64 |
+
COPY backend/requirements.txt .
|
| 65 |
+
|
| 66 |
+
# ββ Frontend (standalone build) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
WORKDIR /app/frontend
|
| 68 |
+
COPY --from=frontend-builder /frontend/.next/standalone ./
|
| 69 |
+
COPY --from=frontend-builder /frontend/.next/static ./.next/static
|
| 70 |
+
COPY --from=frontend-builder /frontend/public ./public
|
| 71 |
+
|
| 72 |
+
# ββ Nginx config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
COPY hf/nginx.conf /etc/nginx/nginx.conf
|
| 74 |
+
|
| 75 |
+
# ββ Supervisord config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
COPY hf/supervisord.conf /etc/supervisor/conf.d/bankbot.conf
|
| 77 |
+
|
| 78 |
+
# ββ Startup script ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
COPY hf/start.sh /app/start.sh
|
| 80 |
+
RUN chmod +x /app/start.sh
|
| 81 |
+
|
| 82 |
+
# ββ Writable dirs for non-root (HF runs as user 1000) ββββββββββββββββββββββββ
|
| 83 |
+
RUN mkdir -p /app/data /var/log/supervisor /var/log/nginx /var/lib/nginx/body \
|
| 84 |
+
&& chmod -R 777 /app/data /var/log/supervisor /var/log/nginx \
|
| 85 |
+
&& chmod -R 777 /var/lib/nginx \
|
| 86 |
+
&& touch /run/nginx.pid && chmod 777 /run/nginx.pid \
|
| 87 |
+
&& chown -R 1000:1000 /app /var/log/supervisor /var/log/nginx /var/lib/nginx
|
| 88 |
+
|
| 89 |
+
# HF Spaces requires port 7860
|
| 90 |
+
EXPOSE 7860
|
| 91 |
+
|
| 92 |
+
# Health check
|
| 93 |
+
HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=5 \
|
| 94 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 95 |
+
|
| 96 |
+
# Run as user 1000 (HF default)
|
| 97 |
+
USER 1000
|
| 98 |
+
|
| 99 |
+
WORKDIR /app
|
| 100 |
+
|
| 101 |
+
CMD ["/app/start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: BankBot AI
|
| 3 |
+
emoji: π¦
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: AI-Native Financial Operating System β real-time streaming, fraud detection, forecasting
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
<div align="center">
|
| 13 |
+
|
| 14 |
+
# π¦ BankBot AI
|
| 15 |
+
|
| 16 |
+
### AI-Native Financial Operating System
|
| 17 |
+
|
| 18 |
+
[](https://fastapi.tiangolo.com)
|
| 19 |
+
[](https://nextjs.org)
|
| 20 |
+
[](https://python.org)
|
| 21 |
+
[](https://typescriptlang.org)
|
| 22 |
+
[](https://docker.com)
|
| 23 |
+
[](https://openai.com)
|
| 24 |
+
|
| 25 |
+
**A production-grade AI fintech platform** with real-time WebSocket streaming, multi-provider AI fallback, fraud detection, financial forecasting, and a premium glassmorphism UI.
|
| 26 |
+
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## π Demo
|
| 32 |
+
|
| 33 |
+
**Login with the demo account:**
|
| 34 |
+
```
|
| 35 |
+
Email: alex@bankbot.dev
|
| 36 |
+
Password: BankBot2026!
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
The demo account includes:
|
| 40 |
+
- **$59,637** across 3 accounts (checking Β· savings Β· investment)
|
| 41 |
+
- **301 transactions** across 6 months
|
| 42 |
+
- **1 fraud alert** (Tech Store NYC, $847, 78% risk score)
|
| 43 |
+
- **4 financial goals** (Emergency Fund Β· Vacation Β· MacBook Β· Down Payment)
|
| 44 |
+
- **4 investments** (S&P 500 Β· AAPL Β· BTC Β· Treasury Bonds)
|
| 45 |
+
- **6 notifications** (3 unread)
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## β¨ Features
|
| 50 |
+
|
| 51 |
+
### π€ AI Financial Twin
|
| 52 |
+
- **Contextual chat** β AI knows your real balance, goals, investments, and spending patterns
|
| 53 |
+
- **4-tier AI fallback**: OpenAI β Groq β Ollama β Rule-based (always responds)
|
| 54 |
+
- **Real-time streaming** via WebSocket β character-by-character with auto-reconnect
|
| 55 |
+
|
| 56 |
+
### π Financial Intelligence
|
| 57 |
+
- **Health Score** β 100-point composite across 6 dimensions
|
| 58 |
+
- **What-If Simulator** β 6 sliders, instant 36-month projection
|
| 59 |
+
- **Spending Heatmap** β weekly behavioral patterns
|
| 60 |
+
- **Category Intelligence** β AI insights per spending category
|
| 61 |
+
|
| 62 |
+
### π‘οΈ Fraud Detection
|
| 63 |
+
- **Real-time scoring** β amount spikes, timing anomalies, rapid-fire, duplicates
|
| 64 |
+
- **Risk levels** β verified / suspicious / flagged
|
| 65 |
+
- **Live alerts** β notification panel with unread count
|
| 66 |
+
|
| 67 |
+
### β‘ Performance
|
| 68 |
+
- Dashboard: **65ms cold, 10ms cached**
|
| 69 |
+
- Cache-aside: Redis β in-memory fallback (automatic)
|
| 70 |
+
- All data endpoints: **< 20ms** warm
|
| 71 |
+
|
| 72 |
+
### π Observability
|
| 73 |
+
- Live metrics at `/api/metrics`
|
| 74 |
+
- System Status page at `/status`
|
| 75 |
+
- Structured JSON logging with request tracing
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## ποΈ Architecture
|
| 80 |
+
|
| 81 |
+
```
|
| 82 |
+
Browser (port 7860)
|
| 83 |
+
β
|
| 84 |
+
βΌ
|
| 85 |
+
Nginx (port 7860) β single entry point
|
| 86 |
+
β β
|
| 87 |
+
βΌ βΌ
|
| 88 |
+
Next.js (3000) FastAPI (8000)
|
| 89 |
+
β β
|
| 90 |
+
ββββββββββββββββββββββ€
|
| 91 |
+
β
|
| 92 |
+
ββββββββββββ΄βββββββββββ
|
| 93 |
+
β β
|
| 94 |
+
SQLite/PostgreSQL Redis/Memory
|
| 95 |
+
(auto-fallback) (auto-fallback)
|
| 96 |
+
β
|
| 97 |
+
ββββββββββββ΄βββββββββββ
|
| 98 |
+
β β β
|
| 99 |
+
OpenAI Groq Ollama
|
| 100 |
+
(P1) (P2) (P3)
|
| 101 |
+
Rule-based (P4)
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## βοΈ Configuration (HF Secrets)
|
| 107 |
+
|
| 108 |
+
Set these in your Space's **Settings β Repository secrets**:
|
| 109 |
+
|
| 110 |
+
| Secret | Required | Description |
|
| 111 |
+
|--------|----------|-------------|
|
| 112 |
+
| `OPENAI_API_KEY` | Optional* | OpenAI GPT-4o-mini |
|
| 113 |
+
| `GROQ_API_KEY` | Optional* | Groq llama-3.3-70b (free) |
|
| 114 |
+
| `JWT_SECRET_KEY` | Recommended | JWT signing secret |
|
| 115 |
+
| `DATABASE_URL` | Optional | External PostgreSQL (Neon/Supabase) |
|
| 116 |
+
| `REDIS_URL` | Optional | External Redis |
|
| 117 |
+
|
| 118 |
+
*At least one AI key recommended. Without any key, the app uses rule-based responses from your actual financial data.
|
| 119 |
+
|
| 120 |
+
**Get a free Groq key:** https://console.groq.com/keys
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## ποΈ Database Options
|
| 125 |
+
|
| 126 |
+
### Option 1: SQLite (Default β works out of the box)
|
| 127 |
+
No configuration needed. Data resets on Space restart (fine for demo).
|
| 128 |
+
|
| 129 |
+
### Option 2: Neon PostgreSQL (Persistent)
|
| 130 |
+
1. Create free DB at https://neon.tech
|
| 131 |
+
2. Set `DATABASE_URL` secret: `postgresql://user:pass@ep-xxx.neon.tech/bankbot?sslmode=require`
|
| 132 |
+
|
| 133 |
+
### Option 3: Supabase PostgreSQL (Persistent)
|
| 134 |
+
1. Create project at https://supabase.com
|
| 135 |
+
2. Set `DATABASE_URL` from Settings β Database β Connection string
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## π‘ API Endpoints
|
| 140 |
+
|
| 141 |
+
```
|
| 142 |
+
GET /health Health check
|
| 143 |
+
GET /api/status Runtime info
|
| 144 |
+
GET /api/metrics Live observability
|
| 145 |
+
GET /docs Interactive API docs
|
| 146 |
+
|
| 147 |
+
POST /api/auth/login Login β JWT
|
| 148 |
+
POST /api/auth/register Register
|
| 149 |
+
GET /api/dashboard/overview Full dashboard (65ms)
|
| 150 |
+
GET /api/transactions/ Transaction history
|
| 151 |
+
GET /api/notifications/ Notifications
|
| 152 |
+
GET /api/ai/coaching/score Health score
|
| 153 |
+
GET /api/ai/fraud/analysis Fraud alerts
|
| 154 |
+
POST /api/ai/chat HTTP chat
|
| 155 |
+
WS /api/ai/chat/ws Streaming chat
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## π οΈ Tech Stack
|
| 161 |
+
|
| 162 |
+
| Layer | Technology |
|
| 163 |
+
|-------|-----------|
|
| 164 |
+
| Frontend | Next.js 14, TypeScript, Tailwind CSS |
|
| 165 |
+
| Animation | Framer Motion |
|
| 166 |
+
| Charts | Recharts |
|
| 167 |
+
| State | Zustand |
|
| 168 |
+
| Backend | FastAPI, Python 3.11 |
|
| 169 |
+
| Database | PostgreSQL / SQLite fallback |
|
| 170 |
+
| Cache | Redis / in-memory fallback |
|
| 171 |
+
| Auth | JWT (python-jose), bcrypt |
|
| 172 |
+
| AI | OpenAI / Groq / Ollama / Rule-based |
|
| 173 |
+
| Container | Docker (single container) |
|
| 174 |
+
| Proxy | Nginx (port 7860) |
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## π Source Code
|
| 179 |
+
|
| 180 |
+
Full source: [GitHub Repository](https://github.com/your-username/bankbot-ai)
|
| 181 |
+
|
| 182 |
+
Documentation:
|
| 183 |
+
- [Architecture](./docs/ARCHITECTURE.md)
|
| 184 |
+
- [API Reference](./docs/API_DOCUMENTATION.md)
|
| 185 |
+
- [Deployment Guide](./docs/DEPLOYMENT_GUIDE.md)
|
| 186 |
+
- [ER Diagram](./docs/ER_DIAGRAM.md)
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# βββ Stage 1: Builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
FROM python:3.11-slim AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /build
|
| 5 |
+
|
| 6 |
+
# System deps for psycopg2 and cryptography
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
build-essential \
|
| 9 |
+
libpq-dev \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# βββ Stage 2: Runtime ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
FROM python:3.11-slim AS runtime
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
# Runtime system deps only
|
| 21 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 22 |
+
libpq5 \
|
| 23 |
+
curl \
|
| 24 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 25 |
+
|
| 26 |
+
# Copy installed packages from builder
|
| 27 |
+
COPY --from=builder /install /usr/local
|
| 28 |
+
|
| 29 |
+
# Copy application code (exclude venv, __pycache__, .env)
|
| 30 |
+
COPY app/ ./app/
|
| 31 |
+
COPY requirements.txt .
|
| 32 |
+
|
| 33 |
+
# Non-root user for security
|
| 34 |
+
RUN useradd -m -u 1001 bankbot && chown -R bankbot:bankbot /app
|
| 35 |
+
USER bankbot
|
| 36 |
+
|
| 37 |
+
EXPOSE 8000
|
| 38 |
+
|
| 39 |
+
# Health check
|
| 40 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
| 41 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 42 |
+
|
| 43 |
+
# Production: no --reload, multiple workers
|
| 44 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
backend/alembic.ini
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
script_location = alembic
|
| 6 |
+
|
| 7 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 8 |
+
# file_template = %%(rev)s_%%(slug)s
|
| 9 |
+
|
| 10 |
+
# timezone to use when rendering the date within the migration file
|
| 11 |
+
# timezone = UTC
|
| 12 |
+
|
| 13 |
+
# max length of characters to apply to the
|
| 14 |
+
# "slug" field
|
| 15 |
+
# truncate_slug_length = 40
|
| 16 |
+
|
| 17 |
+
# set to 'true' to run the environment during
|
| 18 |
+
# the 'revision' command, regardless of autogenerate
|
| 19 |
+
# revision_environment = false
|
| 20 |
+
|
| 21 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 22 |
+
# a source .py file to be detected as revisions in the
|
| 23 |
+
# versions/ directory
|
| 24 |
+
# sourceless = false
|
| 25 |
+
|
| 26 |
+
# version location specification; This defaults
|
| 27 |
+
# to alembic/versions. When using multiple version
|
| 28 |
+
# directories, initial revisions must be specified with --version-path.
|
| 29 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
| 30 |
+
|
| 31 |
+
# the output encoding used when revision files
|
| 32 |
+
# are written from script.py.mako
|
| 33 |
+
# output_encoding = utf-8
|
| 34 |
+
|
| 35 |
+
sqlalchemy.url = postgresql://admin:adminpassword@localhost:5432/bankbot
|
| 36 |
+
|
| 37 |
+
[post_write_hooks]
|
| 38 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 39 |
+
# on newly generated revision scripts. See the documentation for further
|
| 40 |
+
# detail and examples
|
| 41 |
+
|
| 42 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 43 |
+
# hooks = black
|
| 44 |
+
# black.type = console_scripts
|
| 45 |
+
# black.entrypoint = black
|
| 46 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 47 |
+
|
| 48 |
+
[loggers]
|
| 49 |
+
keys = root,sqlalchemy,alembic
|
| 50 |
+
|
| 51 |
+
[handlers]
|
| 52 |
+
keys = console
|
| 53 |
+
|
| 54 |
+
[formatters]
|
| 55 |
+
keys = generic
|
| 56 |
+
|
| 57 |
+
[logger_root]
|
| 58 |
+
level = WARN
|
| 59 |
+
handlers = console
|
| 60 |
+
qualname =
|
| 61 |
+
|
| 62 |
+
[logger_sqlalchemy]
|
| 63 |
+
level = WARN
|
| 64 |
+
handlers =
|
| 65 |
+
qualname = sqlalchemy.engine
|
| 66 |
+
|
| 67 |
+
[logger_alembic]
|
| 68 |
+
level = INFO
|
| 69 |
+
handlers =
|
| 70 |
+
qualname = alembic
|
| 71 |
+
|
| 72 |
+
[handler_console]
|
| 73 |
+
class = StreamHandler
|
| 74 |
+
args = (sys.stderr,)
|
| 75 |
+
level = NOTSET
|
| 76 |
+
formatter = generic
|
| 77 |
+
|
| 78 |
+
[formatter_generic]
|
| 79 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 80 |
+
datefmt = %H:%M:%S
|
backend/alembic/env.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import engine_from_config
|
| 6 |
+
from sqlalchemy import pool
|
| 7 |
+
|
| 8 |
+
from alembic import context
|
| 9 |
+
|
| 10 |
+
# Add parent directory to path to import app modules
|
| 11 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
|
| 13 |
+
from app.database.models import Base
|
| 14 |
+
from app.database.database import SQLALCHEMY_DATABASE_URL
|
| 15 |
+
|
| 16 |
+
# this is the Alembic Config object, which provides
|
| 17 |
+
# access to the values within the .ini file in use.
|
| 18 |
+
config = context.config
|
| 19 |
+
|
| 20 |
+
# Interpret the config file for Python logging.
|
| 21 |
+
# This line sets up loggers basically.
|
| 22 |
+
if config.config_file_name is not None:
|
| 23 |
+
fileConfig(config.config_file_name)
|
| 24 |
+
|
| 25 |
+
# add your model's MetaData object here
|
| 26 |
+
# for 'autogenerate' support
|
| 27 |
+
target_metadata = Base.metadata
|
| 28 |
+
|
| 29 |
+
# Set sqlalchemy.url from the environment or default
|
| 30 |
+
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
|
| 31 |
+
|
| 32 |
+
def run_migrations_offline() -> None:
|
| 33 |
+
"""Run migrations in 'offline' mode.
|
| 34 |
+
|
| 35 |
+
This configures the context with just a URL
|
| 36 |
+
and not an Engine, though an Engine is acceptable
|
| 37 |
+
here as well. By skipping the Engine creation
|
| 38 |
+
we don't even need a DBAPI to be available.
|
| 39 |
+
|
| 40 |
+
Calls to context.execute() here emit the given string to the
|
| 41 |
+
script output.
|
| 42 |
+
|
| 43 |
+
"""
|
| 44 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 45 |
+
context.configure(
|
| 46 |
+
url=url,
|
| 47 |
+
target_metadata=target_metadata,
|
| 48 |
+
literal_binds=True,
|
| 49 |
+
dialect_opts={"paramstyle": "named"},
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
with context.begin_transaction():
|
| 53 |
+
context.run_migrations()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def run_migrations_online() -> None:
|
| 57 |
+
"""Run migrations in 'online' mode.
|
| 58 |
+
|
| 59 |
+
In this scenario we need to create an Engine
|
| 60 |
+
and associate a connection with the context.
|
| 61 |
+
|
| 62 |
+
"""
|
| 63 |
+
connectable = engine_from_config(
|
| 64 |
+
config.get_section(config.config_ini_section, {}),
|
| 65 |
+
prefix="sqlalchemy.",
|
| 66 |
+
poolclass=pool.NullPool,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
with connectable.connect() as connection:
|
| 70 |
+
context.configure(
|
| 71 |
+
connection=connection, target_metadata=target_metadata
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
with context.begin_transaction():
|
| 75 |
+
context.run_migrations()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if context.is_offline_mode():
|
| 79 |
+
run_migrations_offline()
|
| 80 |
+
else:
|
| 81 |
+
run_migrations_online()
|
backend/alembic/script.py.mako
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/ai/behavior.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
import numpy as np
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from app.database.models import Account, Transaction
|
| 6 |
+
|
| 7 |
+
def analyze_spending_behavior(db: Session, user_id: str, days: int = 90):
|
| 8 |
+
"""
|
| 9 |
+
Analyzes historical transactions to detect behavioral patterns (late-night, impulsive, dopamine, stress).
|
| 10 |
+
"""
|
| 11 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 12 |
+
account_ids = [acc.id for acc in accounts]
|
| 13 |
+
|
| 14 |
+
if not account_ids:
|
| 15 |
+
return {"insights": [], "metrics": {}}
|
| 16 |
+
|
| 17 |
+
cutoff = datetime.utcnow() - timedelta(days=days)
|
| 18 |
+
txns = db.query(Transaction).filter(
|
| 19 |
+
Transaction.account_id.in_(account_ids),
|
| 20 |
+
Transaction.timestamp >= cutoff,
|
| 21 |
+
Transaction.type == "debit"
|
| 22 |
+
).all()
|
| 23 |
+
|
| 24 |
+
if not txns:
|
| 25 |
+
return {
|
| 26 |
+
"insights": ["No recent debit transactions to analyze. Complete a few purchases to start behavioral profiling."],
|
| 27 |
+
"metrics": {
|
| 28 |
+
"late_night_count": 0,
|
| 29 |
+
"late_night_total": 0.0,
|
| 30 |
+
"weekend_pct": 0.0,
|
| 31 |
+
"impulsive_count": 0,
|
| 32 |
+
"impulsive_total": 0.0,
|
| 33 |
+
"dopamine_count": 0,
|
| 34 |
+
"stress_count": 0
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Analyze variables
|
| 39 |
+
late_night_txns = []
|
| 40 |
+
weekend_txns = []
|
| 41 |
+
impulsive_txns = []
|
| 42 |
+
dopamine_txns = []
|
| 43 |
+
stress_txns = []
|
| 44 |
+
|
| 45 |
+
amounts = [t.amount for t in txns]
|
| 46 |
+
avg_txn = np.mean(amounts)
|
| 47 |
+
std_txn = np.std(amounts) if len(amounts) > 1 else 0.0
|
| 48 |
+
|
| 49 |
+
category_totals = defaultdict(float)
|
| 50 |
+
hourly_counts = defaultdict(int)
|
| 51 |
+
|
| 52 |
+
for t in txns:
|
| 53 |
+
# Categorize
|
| 54 |
+
category_totals[t.category or "Other"] += t.amount
|
| 55 |
+
|
| 56 |
+
# Timing
|
| 57 |
+
hour = t.timestamp.hour
|
| 58 |
+
hourly_counts[hour] += 1
|
| 59 |
+
|
| 60 |
+
# Late-night spending (11PM to 4AM)
|
| 61 |
+
if hour >= 23 or hour < 4:
|
| 62 |
+
late_night_txns.append(t)
|
| 63 |
+
|
| 64 |
+
# Weekend spending (Friday, Saturday, Sunday)
|
| 65 |
+
# weekday() is 0=Monday, 4=Friday, 5=Saturday, 6=Sunday
|
| 66 |
+
day = t.timestamp.weekday()
|
| 67 |
+
if day in [4, 5, 6]:
|
| 68 |
+
weekend_txns.append(t)
|
| 69 |
+
|
| 70 |
+
# Impulsive spending (More than average + 1.5 * standard dev, or marked as 'regret')
|
| 71 |
+
if t.amount > (avg_txn + 1.5 * std_txn) and (t.category in ["Shopping", "Entertainment", "Food"]):
|
| 72 |
+
impulsive_txns.append(t)
|
| 73 |
+
|
| 74 |
+
# Emotion tags
|
| 75 |
+
emotion = (t.spending_emotion_label or "").lower()
|
| 76 |
+
if emotion == "regret":
|
| 77 |
+
stress_txns.append(t)
|
| 78 |
+
elif emotion in ["happy", "dopamine"] or (t.category == "Shopping" and t.amount > avg_txn):
|
| 79 |
+
dopamine_txns.append(t)
|
| 80 |
+
|
| 81 |
+
# Insights construction
|
| 82 |
+
insights = []
|
| 83 |
+
|
| 84 |
+
# 1. Late night alert
|
| 85 |
+
late_night_pct = (len(late_night_txns) / len(txns) * 100) if txns else 0
|
| 86 |
+
if late_night_pct > 15:
|
| 87 |
+
total_late = sum(t.amount for t in late_night_txns)
|
| 88 |
+
insights.append(
|
| 89 |
+
f"π High late-night spending: {late_night_pct:.1f}% of transactions occur after 11PM (Total: ${total_late:,.2f}). "
|
| 90 |
+
"Consider setting a bedtime blocker on your bank card."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# 2. Weekend overspending
|
| 94 |
+
weekend_pct = (len(weekend_txns) / len(txns) * 100) if txns else 0
|
| 95 |
+
if weekend_pct > 45:
|
| 96 |
+
weekend_avg = np.mean([t.amount for t in weekend_txns]) if weekend_txns else 0
|
| 97 |
+
weekday_txns = [t for t in txns if t not in weekend_txns]
|
| 98 |
+
weekday_avg = np.mean([t.amount for t in weekday_txns]) if weekday_txns else 0
|
| 99 |
+
|
| 100 |
+
if weekend_avg > weekday_avg * 1.2:
|
| 101 |
+
pct_diff = ((weekend_avg - weekday_avg) / weekday_avg) * 100
|
| 102 |
+
insights.append(
|
| 103 |
+
f"π Weekend Spikes: You spend {pct_diff:.1f}% more on weekends than weekdays. "
|
| 104 |
+
"Mainly driven by dining out and recreational purchases."
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# 3. Dopamine triggers
|
| 108 |
+
if len(dopamine_txns) > 3:
|
| 109 |
+
insights.append(
|
| 110 |
+
f"ποΈ Dopamine Spending: Detected {len(dopamine_txns)} shopping spikes. "
|
| 111 |
+
"These purchases often occur in bursts, indicating reward-seeking behavior."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# 4. Stress/Regret Spending
|
| 115 |
+
if len(stress_txns) > 0:
|
| 116 |
+
insights.append(
|
| 117 |
+
f"β οΈ Emotional Spending: You flagged {len(stress_txns)} transactions as 'regret' or 'stress spending'. "
|
| 118 |
+
"Implementing a 24-hour cooling-off rule for non-essential items over $100 could help."
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# General fallback if no major insights
|
| 122 |
+
if not insights:
|
| 123 |
+
insights.append("π Spending Discipline: Your transactions exhibit stable and regular timing, with minimal signs of emotional or impulsive spending.")
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
"insights": insights,
|
| 127 |
+
"metrics": {
|
| 128 |
+
"late_night_count": len(late_night_txns),
|
| 129 |
+
"late_night_total": round(sum(t.amount for t in late_night_txns), 2),
|
| 130 |
+
"weekend_pct": round(weekend_pct, 2),
|
| 131 |
+
"impulsive_count": len(impulsive_txns),
|
| 132 |
+
"impulsive_total": round(sum(t.amount for t in impulsive_txns), 2),
|
| 133 |
+
"dopamine_count": len(dopamine_txns),
|
| 134 |
+
"stress_count": len(stress_txns),
|
| 135 |
+
"avg_transaction_amount": round(avg_txn, 2)
|
| 136 |
+
},
|
| 137 |
+
"category_breakdown": {cat: round(amt, 2) for cat, amt in category_totals.items()}
|
| 138 |
+
}
|
backend/app/ai/budget_planner.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smart Budget Planner for BankBot
|
| 3 |
+
Categorizes spending and provides budgeting insights
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
import uuid
|
| 13 |
+
|
| 14 |
+
BUDGET_FILE = "budgets.json"
|
| 15 |
+
|
| 16 |
+
# Category keywords for automatic categorization
|
| 17 |
+
CATEGORY_KEYWORDS = {
|
| 18 |
+
"Food & Dining": ["restaurant", "food", "cafe", "pizza", "burger", "biryani", "zomato", "swiggy", "coffee", "tea", "meal"],
|
| 19 |
+
"Shopping": ["shop", "store", "mall", "amazon", "flipkart", "ebay", "retail", "boutique", "apparel", "clothes"],
|
| 20 |
+
"Travel": ["uber", "taxi", "bus", "flight", "train", "travel", "hotel", "airline", "booking", "transport"],
|
| 21 |
+
"Entertainment": ["movie", "cinema", "game", "netflix", "spotify", "music", "ticket", "concert", "show"],
|
| 22 |
+
"Bills & Utilities": ["electricity", "water", "gas", "internet", "mobile", "phone", "bill", "subscription"],
|
| 23 |
+
"Healthcare": ["hospital", "doctor", "pharmacy", "medical", "health", "clinic", "medicine"],
|
| 24 |
+
"Groceries": ["grocery", "supermarket", "vegetables", "fruits", "milk", "wheat", "bazar"],
|
| 25 |
+
"Fitness": ["gym", "yoga", "fitness", "sports", "training", "coach"],
|
| 26 |
+
"Insurance": ["insurance", "premium", "policy"],
|
| 27 |
+
"Education": ["school", "college", "course", "book", "tuition", "fees"],
|
| 28 |
+
"Loan & EMI": ["loan", "emi", "mortgage", "credit"],
|
| 29 |
+
"Transfer": ["transfer", "sent", "payment"]
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
class BudgetPlanner:
|
| 33 |
+
"""Smart budget planning and expense tracking"""
|
| 34 |
+
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self.budgets = self.load_budgets()
|
| 37 |
+
|
| 38 |
+
def load_budgets(self):
|
| 39 |
+
"""Load saved budgets from file"""
|
| 40 |
+
if os.path.exists(BUDGET_FILE):
|
| 41 |
+
try:
|
| 42 |
+
with open(BUDGET_FILE, "r", encoding="utf-8") as f:
|
| 43 |
+
return json.load(f)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"Error loading budgets: {e}")
|
| 46 |
+
return {}
|
| 47 |
+
return {}
|
| 48 |
+
|
| 49 |
+
def save_budgets(self):
|
| 50 |
+
"""Save budgets to file"""
|
| 51 |
+
try:
|
| 52 |
+
with open(BUDGET_FILE, "w", encoding="utf-8") as f:
|
| 53 |
+
json.dump(self.budgets, f, indent=4, ensure_ascii=False)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
print(f"Error saving budgets: {e}")
|
| 56 |
+
|
| 57 |
+
def categorize_transaction(self, description, amount=0):
|
| 58 |
+
"""
|
| 59 |
+
Automatically categorize a transaction based on description
|
| 60 |
+
Returns: Category name
|
| 61 |
+
"""
|
| 62 |
+
description_lower = description.lower()
|
| 63 |
+
|
| 64 |
+
# Check against keywords
|
| 65 |
+
for category, keywords in CATEGORY_KEYWORDS.items():
|
| 66 |
+
if any(keyword in description_lower for keyword in keywords):
|
| 67 |
+
return category
|
| 68 |
+
|
| 69 |
+
# Default category
|
| 70 |
+
return "Other"
|
| 71 |
+
|
| 72 |
+
def set_budget_limit(self, username, category, limit):
|
| 73 |
+
"""Set budget limit for a spending category"""
|
| 74 |
+
if username not in self.budgets:
|
| 75 |
+
self.budgets[username] = {}
|
| 76 |
+
|
| 77 |
+
self.budgets[username][category] = {
|
| 78 |
+
"limit": limit,
|
| 79 |
+
"created_at": datetime.now().isoformat(),
|
| 80 |
+
"alerts": []
|
| 81 |
+
}
|
| 82 |
+
self.save_budgets()
|
| 83 |
+
|
| 84 |
+
def analyze_spending(self, username, transactions, period_days=30):
|
| 85 |
+
"""
|
| 86 |
+
Analyze spending by category for a given period
|
| 87 |
+
Returns: Categorized spending data
|
| 88 |
+
"""
|
| 89 |
+
if not transactions:
|
| 90 |
+
return {}
|
| 91 |
+
|
| 92 |
+
# Filter transactions from last N days
|
| 93 |
+
cutoff_date = datetime.now() - timedelta(days=period_days)
|
| 94 |
+
recent_txns = []
|
| 95 |
+
|
| 96 |
+
for txn in transactions:
|
| 97 |
+
try:
|
| 98 |
+
txn_date = datetime.fromisoformat(txn.get('date', ''))
|
| 99 |
+
if txn_date > cutoff_date and txn.get('type') == 'debit':
|
| 100 |
+
recent_txns.append(txn)
|
| 101 |
+
except:
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
# Categorize transactions
|
| 105 |
+
spending_by_category = defaultdict(float)
|
| 106 |
+
categorized_txns = defaultdict(list)
|
| 107 |
+
|
| 108 |
+
for txn in recent_txns:
|
| 109 |
+
category = self.categorize_transaction(
|
| 110 |
+
txn.get('description', txn.get('details', '')),
|
| 111 |
+
float(txn.get('amount', 0))
|
| 112 |
+
)
|
| 113 |
+
amount = float(txn.get('amount', 0))
|
| 114 |
+
spending_by_category[category] += amount
|
| 115 |
+
categorized_txns[category].append({
|
| 116 |
+
'date': txn.get('date'),
|
| 117 |
+
'amount': amount,
|
| 118 |
+
'details': txn.get('details', '')
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"period_days": period_days,
|
| 123 |
+
"spending_by_category": dict(spending_by_category),
|
| 124 |
+
"categorized_transactions": dict(categorized_txns),
|
| 125 |
+
"total_spending": sum(spending_by_category.values()),
|
| 126 |
+
"transaction_count": len(recent_txns)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
def check_budget_alerts(self, username, spending_analysis):
|
| 130 |
+
"""Check if any spending categories exceed their budgets"""
|
| 131 |
+
alerts = []
|
| 132 |
+
|
| 133 |
+
if username not in self.budgets:
|
| 134 |
+
return alerts
|
| 135 |
+
|
| 136 |
+
user_budgets = self.budgets.get(username, {})
|
| 137 |
+
spending = spending_analysis.get('spending_by_category', {})
|
| 138 |
+
|
| 139 |
+
for category, budget_info in user_budgets.items():
|
| 140 |
+
if category not in spending:
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
spent = spending[category]
|
| 144 |
+
limit = budget_info.get('limit', 0)
|
| 145 |
+
|
| 146 |
+
if spent > limit:
|
| 147 |
+
percentage = (spent / limit) * 100
|
| 148 |
+
alerts.append({
|
| 149 |
+
"category": category,
|
| 150 |
+
"spent": round(spent, 2),
|
| 151 |
+
"limit": limit,
|
| 152 |
+
"percentage": round(percentage, 1),
|
| 153 |
+
"excess": round(spent - limit, 2),
|
| 154 |
+
"severity": "high" if percentage > 150 else "medium" if percentage > 100 else "low",
|
| 155 |
+
"timestamp": datetime.now().isoformat()
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
return alerts
|
| 159 |
+
|
| 160 |
+
def generate_budget_plan(self, username, transactions, monthly_income=50000):
|
| 161 |
+
"""Generate recommended budget plan based on spending patterns"""
|
| 162 |
+
spending_analysis = self.analyze_spending(username, transactions, period_days=90)
|
| 163 |
+
spending = spending_analysis.get('spending_by_category', {})
|
| 164 |
+
|
| 165 |
+
total_spending = spending_analysis.get('total_spending', 0)
|
| 166 |
+
avg_monthly_spending = total_spending / 3 if total_spending > 0 else 0
|
| 167 |
+
|
| 168 |
+
# Calculate budget percentages (50/30/20 rule variant)
|
| 169 |
+
recommended_budgets = {}
|
| 170 |
+
|
| 171 |
+
if spending:
|
| 172 |
+
for category, amount in spending.items():
|
| 173 |
+
percentage = (amount / total_spending * 100) if total_spending > 0 else 0
|
| 174 |
+
recommended_budget = (percentage / 100) * monthly_income
|
| 175 |
+
recommended_budgets[category] = round(recommended_budget, 2)
|
| 176 |
+
|
| 177 |
+
# Add default categories if not present
|
| 178 |
+
default_categories = {
|
| 179 |
+
"Food & Dining": monthly_income * 0.08,
|
| 180 |
+
"Shopping": monthly_income * 0.10,
|
| 181 |
+
"Travel": monthly_income * 0.08,
|
| 182 |
+
"Bills & Utilities": monthly_income * 0.15,
|
| 183 |
+
"Entertainment": monthly_income * 0.05,
|
| 184 |
+
"Savings": monthly_income * 0.20,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
for category, amount in default_categories.items():
|
| 188 |
+
if category not in recommended_budgets:
|
| 189 |
+
recommended_budgets[category] = amount
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"monthly_income": monthly_income,
|
| 193 |
+
"current_monthly_avg": round(avg_monthly_spending, 2),
|
| 194 |
+
"recommended_budgets": recommended_budgets,
|
| 195 |
+
"savings_potential": round(monthly_income - avg_monthly_spending, 2),
|
| 196 |
+
"budget_breakdown": {
|
| 197 |
+
"essentials": round(monthly_income * 0.50, 2), # Bills, groceries, insurance
|
| 198 |
+
"lifestyle": round(monthly_income * 0.30, 2), # Entertainment, dining, shopping
|
| 199 |
+
"savings": round(monthly_income * 0.20, 2) # Emergency fund, investments
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
def predict_monthly_spending(self, username, transactions):
|
| 204 |
+
"""
|
| 205 |
+
Predict future spending using historical data
|
| 206 |
+
Returns: Predicted spending for next month
|
| 207 |
+
"""
|
| 208 |
+
if not transactions:
|
| 209 |
+
return {}
|
| 210 |
+
|
| 211 |
+
# Analyze last 3 months
|
| 212 |
+
predictions = {}
|
| 213 |
+
|
| 214 |
+
for period in [30, 60, 90]:
|
| 215 |
+
analysis = self.analyze_spending(username, transactions, period_days=period)
|
| 216 |
+
spending = analysis.get('spending_by_category', {})
|
| 217 |
+
|
| 218 |
+
# Calculate trends
|
| 219 |
+
for category, amount in spending.items():
|
| 220 |
+
if category not in predictions:
|
| 221 |
+
predictions[category] = []
|
| 222 |
+
predictions[category].append(amount)
|
| 223 |
+
|
| 224 |
+
# Calculate averages and trends
|
| 225 |
+
predicted_spending = {}
|
| 226 |
+
for category, amounts in predictions.items():
|
| 227 |
+
if amounts:
|
| 228 |
+
predicted_spending[category] = {
|
| 229 |
+
"predicted": round(np.mean(amounts), 2),
|
| 230 |
+
"trend": "increasing" if amounts[-1] > amounts[0] else "decreasing",
|
| 231 |
+
"variance": round(np.std(amounts), 2)
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
return predicted_spending
|
| 235 |
+
|
| 236 |
+
def get_savings_suggestions(self, username, spending_analysis, monthly_income=50000):
|
| 237 |
+
"""Generate specific savings suggestions"""
|
| 238 |
+
suggestions = []
|
| 239 |
+
spending = spending_analysis.get('spending_by_category', {})
|
| 240 |
+
|
| 241 |
+
# Check each category and provide suggestions
|
| 242 |
+
for category, amount in spending.items():
|
| 243 |
+
percentage = (amount / monthly_income) * 100 if monthly_income > 0 else 0
|
| 244 |
+
|
| 245 |
+
if category == "Food & Dining" and percentage > 10:
|
| 246 |
+
reduction = amount - (monthly_income * 0.08)
|
| 247 |
+
suggestions.append({
|
| 248 |
+
"category": "Food & Dining",
|
| 249 |
+
"potential_savings": round(reduction, 2),
|
| 250 |
+
"suggestion": f"You can save βΉ{round(reduction, 2)} by reducing dining expenses by 10%",
|
| 251 |
+
"priority": "high" if reduction > 1000 else "medium"
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
elif category == "Shopping" and percentage > 12:
|
| 255 |
+
reduction = amount - (monthly_income * 0.10)
|
| 256 |
+
suggestions.append({
|
| 257 |
+
"category": "Shopping",
|
| 258 |
+
"potential_savings": round(reduction, 2),
|
| 259 |
+
"suggestion": f"Reduce impulse purchases to save βΉ{round(reduction, 2)} monthly",
|
| 260 |
+
"priority": "high" if reduction > 1000 else "medium"
|
| 261 |
+
})
|
| 262 |
+
|
| 263 |
+
elif category == "Entertainment" and percentage > 7:
|
| 264 |
+
reduction = amount - (monthly_income * 0.05)
|
| 265 |
+
suggestions.append({
|
| 266 |
+
"category": "Entertainment",
|
| 267 |
+
"potential_savings": round(reduction, 2),
|
| 268 |
+
"suggestion": f"Optimize subscriptions and entertainment to save βΉ{round(reduction, 2)}",
|
| 269 |
+
"priority": "low"
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
# Overall savings tip
|
| 273 |
+
total_savings = sum(s.get('potential_savings', 0) for s in suggestions)
|
| 274 |
+
if total_savings > 0:
|
| 275 |
+
suggestions.append({
|
| 276 |
+
"category": "Total Potential Savings",
|
| 277 |
+
"potential_savings": round(total_savings, 2),
|
| 278 |
+
"suggestion": f"By following these suggestions, you can save βΉ{round(total_savings, 2)} per month",
|
| 279 |
+
"priority": "high"
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
return suggestions
|
| 283 |
+
|
| 284 |
+
def get_budget_insights(username, transactions, users_data):
|
| 285 |
+
"""Get comprehensive budget insights for a user"""
|
| 286 |
+
planner = BudgetPlanner()
|
| 287 |
+
|
| 288 |
+
user_data = users_data.get(username, {})
|
| 289 |
+
monthly_income = user_data.get('monthly_income', 50000)
|
| 290 |
+
|
| 291 |
+
spending_analysis = planner.analyze_spending(username, transactions)
|
| 292 |
+
budget_alerts = planner.check_budget_alerts(username, spending_analysis)
|
| 293 |
+
budget_plan = planner.generate_budget_plan(username, transactions, monthly_income)
|
| 294 |
+
savings_suggestions = planner.get_savings_suggestions(username, spending_analysis, monthly_income)
|
| 295 |
+
predicted_spending = planner.predict_monthly_spending(username, transactions)
|
| 296 |
+
|
| 297 |
+
return {
|
| 298 |
+
"spending_analysis": spending_analysis,
|
| 299 |
+
"budget_alerts": budget_alerts,
|
| 300 |
+
"budget_plan": budget_plan,
|
| 301 |
+
"savings_suggestions": savings_suggestions,
|
| 302 |
+
"predicted_spending": predicted_spending
|
| 303 |
+
}
|
backend/app/ai/chat.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from threading import Lock
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from app.database.models import User, Account, Transaction, Goal, Investment, Subscription
|
| 6 |
+
from app.ai.behavior import analyze_spending_behavior
|
| 7 |
+
from app.ai.coaching import calculate_financial_health_score
|
| 8 |
+
from app.ai.ollama_integration import get_groq_response, get_ollama_response, stream_groq_response, stream_ollama_response
|
| 9 |
+
|
| 10 |
+
# Thread-safe chatbot memory storage
|
| 11 |
+
class ChatMemoryManager:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self._history = {}
|
| 14 |
+
self._lock = Lock()
|
| 15 |
+
|
| 16 |
+
def get_history(self, user_id: str):
|
| 17 |
+
with self._lock:
|
| 18 |
+
if user_id not in self._history:
|
| 19 |
+
self._history[user_id] = []
|
| 20 |
+
return self._history[user_id]
|
| 21 |
+
|
| 22 |
+
def add_message(self, user_id: str, role: str, content: str):
|
| 23 |
+
with self._lock:
|
| 24 |
+
if user_id not in self._history:
|
| 25 |
+
self._history[user_id] = []
|
| 26 |
+
self._history[user_id].append({"role": role, "content": content})
|
| 27 |
+
# Limit history to last 12 messages (6 rounds)
|
| 28 |
+
if len(self._history[user_id]) > 12:
|
| 29 |
+
self._history[user_id] = self._history[user_id][-12:]
|
| 30 |
+
|
| 31 |
+
def clear_history(self, user_id: str):
|
| 32 |
+
with self._lock:
|
| 33 |
+
if user_id in self._history:
|
| 34 |
+
self._history[user_id] = []
|
| 35 |
+
|
| 36 |
+
chat_memory = ChatMemoryManager()
|
| 37 |
+
|
| 38 |
+
def build_user_context_string(db: Session, user_id: str) -> str:
|
| 39 |
+
"""
|
| 40 |
+
Queries database for a user's entire financial situation to construct a precise system context.
|
| 41 |
+
"""
|
| 42 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 43 |
+
if not user:
|
| 44 |
+
return "No user information available."
|
| 45 |
+
|
| 46 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 47 |
+
total_balance = sum(acc.balance for acc in accounts)
|
| 48 |
+
account_details = [f"{acc.type.capitalize()} Account: ${acc.balance:,.2f}" for acc in accounts]
|
| 49 |
+
|
| 50 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 51 |
+
goals_details = [f"Goal '{g.title}': Target ${g.target_amount:,.2f}, Saved ${g.current_amount:,.2f}" for g in goals]
|
| 52 |
+
|
| 53 |
+
investments = db.query(Investment).filter(Investment.user_id == user_id).all()
|
| 54 |
+
investments_details = [f"{i.asset_name} ({i.type}): invested ${i.amount_invested:,.2f}, Current Value ${i.current_value:,.2f}" for i in investments]
|
| 55 |
+
|
| 56 |
+
subs = db.query(Subscription).filter(Subscription.user_id == user_id, Subscription.active == True).all()
|
| 57 |
+
subs_details = [f"{s.merchant}: ${s.amount:,.2f}/{s.billing_cycle}" for s in subs]
|
| 58 |
+
|
| 59 |
+
# Run behavioral diagnostics
|
| 60 |
+
behavior = analyze_spending_behavior(db, user_id)
|
| 61 |
+
behavior_insights = behavior.get("insights", [])
|
| 62 |
+
|
| 63 |
+
# Financial Score
|
| 64 |
+
score_data = calculate_financial_health_score(db, user_id)
|
| 65 |
+
financial_score = score_data.get("overall_score", 50)
|
| 66 |
+
|
| 67 |
+
context = f"""
|
| 68 |
+
User Profile:
|
| 69 |
+
- Name: {user.profile_data.get('name', 'Client')}
|
| 70 |
+
- Financial Personality: {user.financial_personality}
|
| 71 |
+
- Financial Health Score: {financial_score:.0f}/100
|
| 72 |
+
|
| 73 |
+
Balances:
|
| 74 |
+
{', '.join(account_details) if account_details else 'No active bank accounts'}
|
| 75 |
+
- Total Liquid Capital: ${total_balance:,.2f}
|
| 76 |
+
|
| 77 |
+
Financial Goals:
|
| 78 |
+
{'; '.join(goals_details) if goals_details else 'None established'}
|
| 79 |
+
|
| 80 |
+
Active Portfolio:
|
| 81 |
+
{'; '.join(investments_details) if investments_details else 'No active investments'}
|
| 82 |
+
|
| 83 |
+
Active Subscriptions:
|
| 84 |
+
{'; '.join(subs_details) if subs_details else 'No active subscriptions'}
|
| 85 |
+
|
| 86 |
+
Diagnostics & Behavior:
|
| 87 |
+
- {'; '.join(behavior_insights)}
|
| 88 |
+
- Late night spending occurrences: {behavior.get('metrics', {}).get('late_night_count', 0)}
|
| 89 |
+
- Weekend spending ratio: {behavior.get('metrics', {}).get('weekend_pct', 0.0)}%
|
| 90 |
+
"""
|
| 91 |
+
return context
|
| 92 |
+
|
| 93 |
+
def get_contextual_system_prompt(db: Session, user_id: str) -> str:
|
| 94 |
+
"""
|
| 95 |
+
Constructs a highly specific system prompt containing the user's financial profile.
|
| 96 |
+
"""
|
| 97 |
+
financial_context = build_user_context_string(db, user_id)
|
| 98 |
+
|
| 99 |
+
system_prompt = f"""You are BankBot, an elite AI Financial Analyst, Wealth Advisor, and Predictive Banking Engine.
|
| 100 |
+
You communicate with the user, providing highly personalized, concise, and mathematically rigorous answers.
|
| 101 |
+
You have direct, read-only access to the client's current financial profile and database records.
|
| 102 |
+
|
| 103 |
+
CURRENT USER PORTFOLIO DATA:
|
| 104 |
+
{financial_context}
|
| 105 |
+
|
| 106 |
+
CORE PRINCIPLES:
|
| 107 |
+
1. NEVER behave like a generic chatbot. Avoid generic suggestions like "save more money". Use real numbers, calculate percentages, and suggest specific actions based on the client's data.
|
| 108 |
+
2. Respond with the authority and brevity of a Bloomberg Terminal analyst.
|
| 109 |
+
3. Keep your answers brief, actionable, and financially meaningful (typically 2-4 sentences max).
|
| 110 |
+
4. If the user asks a question about their spending, goals, or predictions, use the portfolio data above.
|
| 111 |
+
5. Always remain helpful, professional, and secure.
|
| 112 |
+
"""
|
| 113 |
+
return system_prompt
|
| 114 |
+
|
| 115 |
+
def get_offline_chat_fallback(db: Session, user_id: str, prompt: str) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Generates a localized, rule-grounded financial analyst reply when AI engines are offline.
|
| 118 |
+
"""
|
| 119 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 120 |
+
persona = user.financial_personality if user else "Saver"
|
| 121 |
+
|
| 122 |
+
prompt_lower = prompt.lower()
|
| 123 |
+
|
| 124 |
+
if "discipline" in prompt_lower or "spend" in prompt_lower or "budget" in prompt_lower:
|
| 125 |
+
score_data = calculate_financial_health_score(db, user_id)
|
| 126 |
+
discipline_score = score_data.get("categories", {}).get("spending_discipline", {}).get("score", 10)
|
| 127 |
+
return (
|
| 128 |
+
f"As a {persona}, your spending discipline score stands at {discipline_score:.0f}/20. "
|
| 129 |
+
f"Analysis of your transaction history shows discretionary spikes. "
|
| 130 |
+
"To optimize your cashflow surplus, establish a strict 20% savings buffer prior to discretionary outflow."
|
| 131 |
+
)
|
| 132 |
+
elif "investment" in prompt_lower or "portfolio" in prompt_lower or "grow" in prompt_lower:
|
| 133 |
+
investments = db.query(Investment).filter(Investment.user_id == user_id).all()
|
| 134 |
+
inv_total = sum(i.current_value for i in investments)
|
| 135 |
+
return (
|
| 136 |
+
f"Your current investment portfolio valuation stands at ${inv_total:,.2f}. "
|
| 137 |
+
"Based on asset performance, shifting 15% of your net checking surplus into stock index funds "
|
| 138 |
+
"will counter inflation and capture a projected 8% compound annual return."
|
| 139 |
+
)
|
| 140 |
+
else:
|
| 141 |
+
score_data = calculate_financial_health_score(db, user_id)
|
| 142 |
+
score = score_data.get("overall_score", 50)
|
| 143 |
+
return (
|
| 144 |
+
f"Wealth Advisor assessment: Your overall Financial Health Score is {score:.0f}/100. "
|
| 145 |
+
"Liquidity is stable, but subscription and discretionary leakages are tempering compounding growth. "
|
| 146 |
+
"Audit duplicate subscriptions and automate goal savings to enhance your trajectory."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
def get_chat_response(db: Session, user_id: str, prompt: str) -> str:
|
| 150 |
+
"""
|
| 151 |
+
Returns an HTTP conversational response grounded in database context.
|
| 152 |
+
"""
|
| 153 |
+
from app.ai.ollama_integration import has_active_ai_backend
|
| 154 |
+
|
| 155 |
+
if not has_active_ai_backend():
|
| 156 |
+
fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
|
| 157 |
+
chat_memory.add_message(user_id, "user", prompt)
|
| 158 |
+
chat_memory.add_message(user_id, "assistant", fallback_msg)
|
| 159 |
+
return fallback_msg
|
| 160 |
+
|
| 161 |
+
sys_prompt = get_contextual_system_prompt(db, user_id)
|
| 162 |
+
history = chat_memory.get_history(user_id)
|
| 163 |
+
|
| 164 |
+
# Construct complete prompt for underlying backend
|
| 165 |
+
full_messages = [{"role": "system", "content": sys_prompt}]
|
| 166 |
+
for msg in history:
|
| 167 |
+
full_messages.append({"role": msg["role"], "content": msg["content"]})
|
| 168 |
+
full_messages.append({"role": "user", "content": prompt})
|
| 169 |
+
|
| 170 |
+
# Determine backend
|
| 171 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 172 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 173 |
+
|
| 174 |
+
response_content = None
|
| 175 |
+
|
| 176 |
+
if OPENAI_API_KEY:
|
| 177 |
+
try:
|
| 178 |
+
from openai import OpenAI
|
| 179 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 180 |
+
res = client.chat.completions.create(
|
| 181 |
+
model="gpt-4o-mini",
|
| 182 |
+
messages=full_messages,
|
| 183 |
+
temperature=0.1,
|
| 184 |
+
max_tokens=500
|
| 185 |
+
)
|
| 186 |
+
response_content = res.choices[0].message.content
|
| 187 |
+
except Exception as e:
|
| 188 |
+
print(f"OpenAI error in chat: {e}")
|
| 189 |
+
|
| 190 |
+
if not response_content and GROQ_API_KEY:
|
| 191 |
+
try:
|
| 192 |
+
response_content = get_groq_response(prompt, history=history, language="English")
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f"Groq error in chat: {e}")
|
| 195 |
+
|
| 196 |
+
if not response_content:
|
| 197 |
+
# Fallback to local Ollama integration
|
| 198 |
+
try:
|
| 199 |
+
response_content = get_ollama_response(prompt, history=history, language="English")
|
| 200 |
+
except Exception as e:
|
| 201 |
+
print(f"Ollama error in chat: {e}")
|
| 202 |
+
|
| 203 |
+
if not response_content:
|
| 204 |
+
response_content = get_offline_chat_fallback(db, user_id, prompt)
|
| 205 |
+
|
| 206 |
+
# Save conversation
|
| 207 |
+
chat_memory.add_message(user_id, "user", prompt)
|
| 208 |
+
chat_memory.add_message(user_id, "assistant", response_content)
|
| 209 |
+
|
| 210 |
+
return response_content
|
| 211 |
+
|
| 212 |
+
def stream_chat_response(db: Session, user_id: str, prompt: str):
|
| 213 |
+
"""
|
| 214 |
+
Generates streaming chunks for WebSocket or HTTP SSE.
|
| 215 |
+
"""
|
| 216 |
+
from app.ai.ollama_integration import has_active_ai_backend
|
| 217 |
+
|
| 218 |
+
if not has_active_ai_backend():
|
| 219 |
+
fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
|
| 220 |
+
chat_memory.add_message(user_id, "user", prompt)
|
| 221 |
+
chat_memory.add_message(user_id, "assistant", fallback_msg)
|
| 222 |
+
# Yield words slowly to simulate streaming
|
| 223 |
+
import time
|
| 224 |
+
for word in fallback_msg.split(" "):
|
| 225 |
+
yield word + " "
|
| 226 |
+
time.sleep(0.05)
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
sys_prompt = get_contextual_system_prompt(db, user_id)
|
| 230 |
+
history = chat_memory.get_history(user_id)
|
| 231 |
+
|
| 232 |
+
full_messages = [{"role": "system", "content": sys_prompt}]
|
| 233 |
+
for msg in history:
|
| 234 |
+
full_messages.append({"role": msg["role"], "content": msg["content"]})
|
| 235 |
+
full_messages.append({"role": "user", "content": prompt})
|
| 236 |
+
|
| 237 |
+
# Save user message to history
|
| 238 |
+
chat_memory.add_message(user_id, "user", prompt)
|
| 239 |
+
|
| 240 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 241 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 242 |
+
|
| 243 |
+
complete_reply = ""
|
| 244 |
+
|
| 245 |
+
if OPENAI_API_KEY:
|
| 246 |
+
try:
|
| 247 |
+
from openai import OpenAI
|
| 248 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 249 |
+
stream = client.chat.completions.create(
|
| 250 |
+
model="gpt-4o-mini",
|
| 251 |
+
messages=full_messages,
|
| 252 |
+
temperature=0.1,
|
| 253 |
+
max_tokens=500,
|
| 254 |
+
stream=True
|
| 255 |
+
)
|
| 256 |
+
for chunk in stream:
|
| 257 |
+
content = chunk.choices[0].delta.content
|
| 258 |
+
if content:
|
| 259 |
+
complete_reply += content
|
| 260 |
+
yield content
|
| 261 |
+
# Save assistant message once streaming completes
|
| 262 |
+
chat_memory.add_message(user_id, "assistant", complete_reply)
|
| 263 |
+
return
|
| 264 |
+
except Exception as e:
|
| 265 |
+
print(f"OpenAI streaming error: {e}")
|
| 266 |
+
|
| 267 |
+
if GROQ_API_KEY:
|
| 268 |
+
try:
|
| 269 |
+
for chunk in stream_groq_response(prompt, history=history, language="English"):
|
| 270 |
+
if chunk:
|
| 271 |
+
complete_reply += chunk
|
| 272 |
+
yield chunk
|
| 273 |
+
chat_memory.add_message(user_id, "assistant", complete_reply)
|
| 274 |
+
return
|
| 275 |
+
except Exception as e:
|
| 276 |
+
print(f"Groq streaming error: {e}")
|
| 277 |
+
|
| 278 |
+
# Fallback to local Ollama stream
|
| 279 |
+
try:
|
| 280 |
+
for chunk in stream_ollama_response(prompt, history=history, language="English"):
|
| 281 |
+
if chunk:
|
| 282 |
+
complete_reply += chunk
|
| 283 |
+
yield chunk
|
| 284 |
+
chat_memory.add_message(user_id, "assistant", complete_reply)
|
| 285 |
+
except Exception as e:
|
| 286 |
+
print(f"Ollama streaming error: {e}")
|
| 287 |
+
fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
|
| 288 |
+
yield fallback_msg
|
| 289 |
+
chat_memory.add_message(user_id, "assistant", fallback_msg)
|
backend/app/ai/coaching.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.database.models import User, Account, Transaction, Goal, Investment, Subscription
|
| 5 |
+
from app.ai.forecasting import get_cashflow_metrics
|
| 6 |
+
from app.ai.ollama_integration import get_ai_response
|
| 7 |
+
|
| 8 |
+
def calculate_financial_health_score(db: Session, user_id: str):
|
| 9 |
+
"""
|
| 10 |
+
Computes a multi-dimensional Financial Health Score (0-100) based on real database records.
|
| 11 |
+
"""
|
| 12 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 13 |
+
total_balance = sum(acc.balance for acc in accounts)
|
| 14 |
+
savings_balance = sum(acc.balance for acc in accounts if acc.type.lower() == "savings")
|
| 15 |
+
|
| 16 |
+
# Cashflow
|
| 17 |
+
current_balance, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 18 |
+
monthly_income = max(1000.0, daily_income * 30.4)
|
| 19 |
+
monthly_spending = daily_spending * 30.4
|
| 20 |
+
|
| 21 |
+
# 1. Savings Consistency (20 pts)
|
| 22 |
+
# Check frequency of saving transactions or goal additions
|
| 23 |
+
txns = db.query(Transaction).join(Account).filter(
|
| 24 |
+
Account.user_id == user_id,
|
| 25 |
+
Transaction.type == "credit",
|
| 26 |
+
Transaction.category == "Income"
|
| 27 |
+
).count()
|
| 28 |
+
# Let's say if they have active goals with current_amount > 0, they get higher points
|
| 29 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 30 |
+
goal_savings = sum(g.current_amount for g in goals)
|
| 31 |
+
|
| 32 |
+
savings_score = 10.0
|
| 33 |
+
if goal_savings > 1000:
|
| 34 |
+
savings_score += 10.0
|
| 35 |
+
elif goal_savings > 0:
|
| 36 |
+
savings_score += 5.0
|
| 37 |
+
|
| 38 |
+
# 2. Debt Ratio (20 pts)
|
| 39 |
+
# Estimate EMIs or goals with "debt"
|
| 40 |
+
debt_goals = sum(g.target_amount - g.current_amount for g in goals if "debt" in g.title.lower() or "loan" in g.title.lower())
|
| 41 |
+
# Standard monthly debt service (estimate 10% of debt or $150 minimum if debt exists)
|
| 42 |
+
est_monthly_debt = max(0.0, debt_goals * 0.05)
|
| 43 |
+
debt_to_income = est_monthly_debt / monthly_income
|
| 44 |
+
|
| 45 |
+
debt_score = 20.0
|
| 46 |
+
if debt_to_income > 0.40:
|
| 47 |
+
debt_score = 5.0
|
| 48 |
+
elif debt_to_income > 0.20:
|
| 49 |
+
debt_score = 12.0
|
| 50 |
+
elif debt_to_income > 0.05:
|
| 51 |
+
debt_score = 18.0
|
| 52 |
+
|
| 53 |
+
# 3. Spending Discipline (20 pts)
|
| 54 |
+
# Ratio of monthly spending to monthly income
|
| 55 |
+
savings_rate = (monthly_income - monthly_spending) / monthly_income if monthly_income > 0 else 0
|
| 56 |
+
discipline_score = 10.0
|
| 57 |
+
if savings_rate >= 0.30:
|
| 58 |
+
discipline_score = 20.0
|
| 59 |
+
elif savings_rate >= 0.15:
|
| 60 |
+
discipline_score = 16.0
|
| 61 |
+
elif savings_rate >= 0.0:
|
| 62 |
+
discipline_score = 12.0
|
| 63 |
+
|
| 64 |
+
# 4. Emergency Fund (20 pts)
|
| 65 |
+
# Do they have 3-6 months of expenses in savings?
|
| 66 |
+
monthly_expenses = max(500.0, monthly_spending)
|
| 67 |
+
months_buffer = savings_balance / monthly_expenses
|
| 68 |
+
|
| 69 |
+
emergency_score = 0.0
|
| 70 |
+
if months_buffer >= 6.0:
|
| 71 |
+
emergency_score = 20.0
|
| 72 |
+
elif months_buffer >= 3.0:
|
| 73 |
+
emergency_score = 15.0
|
| 74 |
+
elif months_buffer >= 1.0:
|
| 75 |
+
emergency_score = 8.0
|
| 76 |
+
|
| 77 |
+
# 5. Investment Index (10 pts)
|
| 78 |
+
investments = db.query(Investment).filter(Investment.user_id == user_id).all()
|
| 79 |
+
inv_total = sum(i.current_value for i in investments)
|
| 80 |
+
|
| 81 |
+
investment_score = 0.0
|
| 82 |
+
if inv_total > 5000:
|
| 83 |
+
investment_score = 10.0
|
| 84 |
+
elif inv_total > 0:
|
| 85 |
+
investment_score = 6.0
|
| 86 |
+
|
| 87 |
+
# 6. Subscription Efficiency (10 pts)
|
| 88 |
+
subs = db.query(Subscription).filter(Subscription.user_id == user_id, Subscription.active == True).all()
|
| 89 |
+
sub_cost = sum(s.amount if s.billing_cycle.lower() == "monthly" else (s.amount / 12) for s in subs)
|
| 90 |
+
sub_ratio = sub_cost / monthly_income
|
| 91 |
+
|
| 92 |
+
sub_score = 10.0
|
| 93 |
+
if sub_ratio > 0.10: # More than 10% of income on subscriptions
|
| 94 |
+
sub_score = 3.0
|
| 95 |
+
elif sub_ratio > 0.05: # More than 5%
|
| 96 |
+
sub_score = 7.0
|
| 97 |
+
|
| 98 |
+
# Calculate Overall Score
|
| 99 |
+
overall_score = savings_score + debt_score + discipline_score + emergency_score + investment_score + sub_score
|
| 100 |
+
overall_score = min(100.0, max(0.0, overall_score))
|
| 101 |
+
|
| 102 |
+
# Actionable improvements list
|
| 103 |
+
improvements = []
|
| 104 |
+
if savings_score < 15:
|
| 105 |
+
improvements.append("Set up automated transfers to your Savings account right after payday.")
|
| 106 |
+
if debt_score < 15:
|
| 107 |
+
improvements.append("Prioritize high-interest debt payoffs using the debt avalanche method.")
|
| 108 |
+
if discipline_score < 15:
|
| 109 |
+
improvements.append("Discretionary spending (shopping & dining) is high. Try implementing a $50 weekly limit.")
|
| 110 |
+
if emergency_score < 15:
|
| 111 |
+
improvements.append(f"Build savings buffer. Try to accumulate at least ${monthly_expenses * 3:,.2f} (3 months of expenses).")
|
| 112 |
+
if investment_score < 6:
|
| 113 |
+
improvements.append("Start a low-cost stock index fund SIP to counter inflation.")
|
| 114 |
+
if sub_score < 8:
|
| 115 |
+
improvements.append("Conduct an audit of active subscriptions. Cancel duplicate/unused memberships.")
|
| 116 |
+
|
| 117 |
+
if not improvements:
|
| 118 |
+
improvements.append("Maintain your current financial habits; your portfolio is highly optimized!")
|
| 119 |
+
|
| 120 |
+
# AI Explanation
|
| 121 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 122 |
+
persona = user.financial_personality if user else "Saver"
|
| 123 |
+
|
| 124 |
+
ai_prompt = f"""
|
| 125 |
+
The user is a '{persona}' with a Financial Health Score of {overall_score:.0f}/100.
|
| 126 |
+
Sub-scores:
|
| 127 |
+
- Savings Consistency: {savings_score:.0f}/20
|
| 128 |
+
- Debt Management: {debt_score:.0f}/20
|
| 129 |
+
- Spending Discipline: {discipline_score:.0f}/20
|
| 130 |
+
- Emergency Fund: {emergency_score:.0f}/20
|
| 131 |
+
- Investment Allocation: {investment_score:.0f}/10
|
| 132 |
+
- Subscription Management: {sub_score:.0f}/10
|
| 133 |
+
|
| 134 |
+
Write a concise, professional financial analyst explanation of this score. Detail the primary strengths and key weaknesses.
|
| 135 |
+
Do NOT write a generic chatbot reply. Keep it to 3-4 sentences. Format like a Bloomberg analyst report.
|
| 136 |
+
"""
|
| 137 |
+
|
| 138 |
+
from app.ai.ollama_integration import has_active_ai_backend
|
| 139 |
+
|
| 140 |
+
explanation = None
|
| 141 |
+
if has_active_ai_backend():
|
| 142 |
+
try:
|
| 143 |
+
# Hard 8-second timeout so the health score endpoint never hangs
|
| 144 |
+
import threading
|
| 145 |
+
result = [None]
|
| 146 |
+
def _call():
|
| 147 |
+
result[0] = get_ai_response(ai_prompt)
|
| 148 |
+
t = threading.Thread(target=_call, daemon=True)
|
| 149 |
+
t.start()
|
| 150 |
+
t.join(timeout=8)
|
| 151 |
+
explanation = result[0]
|
| 152 |
+
except Exception:
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
if not explanation:
|
| 156 |
+
explanation = f"As a {persona}, your financial health score of {overall_score:.0f} reflects solid fundamentals with opportunities to optimize emergency allocations and subscription efficiencies. Focus on automating savings and expanding investments."
|
| 157 |
+
|
| 158 |
+
return {
|
| 159 |
+
"overall_score": round(overall_score, 0),
|
| 160 |
+
"categories": {
|
| 161 |
+
"savings_consistency": {"score": round(savings_score, 0), "max": 20},
|
| 162 |
+
"debt_ratio": {"score": round(debt_score, 0), "max": 20},
|
| 163 |
+
"spending_discipline": {"score": round(discipline_score, 0), "max": 20},
|
| 164 |
+
"emergency_funds": {"score": round(emergency_score, 0), "max": 20},
|
| 165 |
+
"investments": {"score": round(investment_score, 0), "max": 10},
|
| 166 |
+
"subscription_management": {"score": round(sub_score, 0), "max": 10}
|
| 167 |
+
},
|
| 168 |
+
"explanation": explanation,
|
| 169 |
+
"actionable_improvements": improvements
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def generate_daily_briefing(db: Session, user_id: str):
|
| 173 |
+
"""
|
| 174 |
+
Pulls complete financial context and generates a personalized daily financial briefing.
|
| 175 |
+
"""
|
| 176 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 177 |
+
if not user:
|
| 178 |
+
return {"briefing": "User not found."}
|
| 179 |
+
|
| 180 |
+
# Collect data
|
| 181 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 182 |
+
total_balance = sum(acc.balance for acc in accounts)
|
| 183 |
+
|
| 184 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 185 |
+
goals_summary = [f"{g.title}: {g.current_amount}/{g.target_amount}" for g in goals]
|
| 186 |
+
|
| 187 |
+
investments = db.query(Investment).filter(Investment.user_id == user_id).all()
|
| 188 |
+
inv_summary = [f"{i.asset_name} ({i.type}): Current Value ${i.current_value:,.2f}" for i in investments]
|
| 189 |
+
|
| 190 |
+
# Cashflow
|
| 191 |
+
current_balance, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 192 |
+
monthly_income = daily_income * 30.4
|
| 193 |
+
monthly_spending = daily_spending * 30.4
|
| 194 |
+
|
| 195 |
+
# Format AI Prompt
|
| 196 |
+
ai_prompt = f"""
|
| 197 |
+
You are an AI Wealth Advisor and Predictive Banking Engine. Generate a personalized daily financial briefing for {user.profile_data.get('name', 'User')}.
|
| 198 |
+
|
| 199 |
+
Financial Summary:
|
| 200 |
+
- User Personality: {user.financial_personality}
|
| 201 |
+
- Total Account Balance: ${total_balance:,.2f}
|
| 202 |
+
- Estimated Monthly Income: ${monthly_income:,.2f}
|
| 203 |
+
- Estimated Monthly Spending: ${monthly_spending:,.2f}
|
| 204 |
+
- Active Goals: {', '.join(goals_summary) if goals_summary else 'None'}
|
| 205 |
+
- Investments: {', '.join(inv_summary) if inv_summary else 'None'}
|
| 206 |
+
|
| 207 |
+
Generate a 3-paragraph daily briefing.
|
| 208 |
+
Paragraph 1: Summary of their current liquidity and portfolio health.
|
| 209 |
+
Paragraph 2: One specific recommendation regarding their savings goals or investment potential.
|
| 210 |
+
Paragraph 3: A behavioral spending insight warning based on their spending velocity.
|
| 211 |
+
|
| 212 |
+
Style: Bloomberg Terminal style, highly intelligent, concise, financially meaningful, human-like.
|
| 213 |
+
Avoid boilerplate generic remarks (e.g. 'You should try saving more money'). Use exact figures.
|
| 214 |
+
"""
|
| 215 |
+
|
| 216 |
+
from app.ai.ollama_integration import has_active_ai_backend
|
| 217 |
+
|
| 218 |
+
briefing = None
|
| 219 |
+
if has_active_ai_backend():
|
| 220 |
+
try:
|
| 221 |
+
import threading
|
| 222 |
+
result = [None]
|
| 223 |
+
def _call():
|
| 224 |
+
result[0] = get_ai_response(ai_prompt)
|
| 225 |
+
t = threading.Thread(target=_call, daemon=True)
|
| 226 |
+
t.start()
|
| 227 |
+
t.join(timeout=10)
|
| 228 |
+
briefing = result[0]
|
| 229 |
+
except Exception:
|
| 230 |
+
pass
|
| 231 |
+
|
| 232 |
+
if not briefing:
|
| 233 |
+
briefing = f"DAILY BRIEFING:\n\nYour liquid capital stands at ${total_balance:,.2f}. Portfolio indicators suggest regular cashflow velocity. Based on your {user.financial_personality} profile, we advise dedicating a portion of your net surplus to your active goals to optimize compound growth. Avoid non-essential weekend dining and retail spikes to maintain your target trajectory."
|
| 234 |
+
|
| 235 |
+
return {
|
| 236 |
+
"date": datetime.utcnow().strftime("%Y-%m-%d"),
|
| 237 |
+
"user_name": user.profile_data.get('name', 'User'),
|
| 238 |
+
"briefing": briefing,
|
| 239 |
+
"metrics": {
|
| 240 |
+
"total_liquid_capital": round(total_balance, 2),
|
| 241 |
+
"monthly_income_projection": round(monthly_income, 2),
|
| 242 |
+
"monthly_burn_rate": round(monthly_spending, 2)
|
| 243 |
+
}
|
| 244 |
+
}
|
backend/app/ai/forecasting.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.database.models import Account, Transaction, Goal, Investment, Subscription
|
| 5 |
+
|
| 6 |
+
def get_cashflow_metrics(db: Session, user_id: str, days: int = 90):
|
| 7 |
+
"""
|
| 8 |
+
Computes daily average income and spending based on historical transactions.
|
| 9 |
+
"""
|
| 10 |
+
# Fetch checking & savings accounts for user
|
| 11 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 12 |
+
account_ids = [acc.id for acc in accounts]
|
| 13 |
+
|
| 14 |
+
if not account_ids:
|
| 15 |
+
return 0.0, 0.0, 0.0 # Current balance, avg daily income, avg daily spending
|
| 16 |
+
|
| 17 |
+
current_balance = sum(acc.balance for acc in accounts)
|
| 18 |
+
|
| 19 |
+
# Fetch recent transactions
|
| 20 |
+
cutoff = datetime.utcnow() - timedelta(days=days)
|
| 21 |
+
txns = db.query(Transaction).filter(
|
| 22 |
+
Transaction.account_id.in_(account_ids),
|
| 23 |
+
Transaction.timestamp >= cutoff
|
| 24 |
+
).all()
|
| 25 |
+
|
| 26 |
+
if not txns:
|
| 27 |
+
return current_balance, 0.0, 0.0
|
| 28 |
+
|
| 29 |
+
total_income = sum(t.amount for t in txns if t.type.lower() == "credit")
|
| 30 |
+
total_spending = sum(t.amount for t in txns if t.type.lower() == "debit")
|
| 31 |
+
|
| 32 |
+
avg_daily_income = total_income / days
|
| 33 |
+
avg_daily_spending = total_spending / days
|
| 34 |
+
|
| 35 |
+
return current_balance, avg_daily_income, avg_daily_spending
|
| 36 |
+
|
| 37 |
+
def predict_future_balance(db: Session, user_id: str, projection_days: int = 90):
|
| 38 |
+
"""
|
| 39 |
+
Predicts future balances and returns trend description.
|
| 40 |
+
"""
|
| 41 |
+
current_balance, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 42 |
+
|
| 43 |
+
net_daily = daily_income - daily_spending
|
| 44 |
+
projected_balance = max(0.0, current_balance + (net_daily * projection_days))
|
| 45 |
+
|
| 46 |
+
# Calculate percentage change
|
| 47 |
+
if current_balance > 0:
|
| 48 |
+
percent_change = (projected_balance - current_balance) / current_balance * 100
|
| 49 |
+
else:
|
| 50 |
+
percent_change = 0.0
|
| 51 |
+
|
| 52 |
+
# Generate human-friendly description
|
| 53 |
+
if percent_change < 0:
|
| 54 |
+
insight = f"If current spending continues, your total balance may decrease by {abs(percent_change):.1f}% (down to ${projected_balance:,.2f}) in {projection_days} days."
|
| 55 |
+
elif percent_change > 0:
|
| 56 |
+
insight = f"Based on current trends, your total balance is projected to grow by {percent_change:.1f}% (up to ${projected_balance:,.2f}) in {projection_days} days."
|
| 57 |
+
else:
|
| 58 |
+
insight = "Your financial trajectory is steady with minor balance fluctuations."
|
| 59 |
+
|
| 60 |
+
# Generate daily data points for charts
|
| 61 |
+
chart_data = []
|
| 62 |
+
for day in range(0, projection_days + 1, 5):
|
| 63 |
+
val = max(0.0, current_balance + (net_daily * day))
|
| 64 |
+
date_str = (datetime.utcnow() + timedelta(days=day)).strftime("%Y-%m-%d")
|
| 65 |
+
chart_data.append({"date": date_str, "balance": round(val, 2)})
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"current_balance": round(current_balance, 2),
|
| 69 |
+
"projected_balance": round(projected_balance, 2),
|
| 70 |
+
"percent_change": round(percent_change, 2),
|
| 71 |
+
"net_daily": round(net_daily, 2),
|
| 72 |
+
"insight": insight,
|
| 73 |
+
"chart_data": chart_data
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
def forecast_savings_and_investments(db: Session, user_id: str, projection_months: int = 12):
|
| 77 |
+
"""
|
| 78 |
+
Projects savings and investment growth.
|
| 79 |
+
"""
|
| 80 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 81 |
+
savings_balance = sum(acc.balance for acc in accounts if acc.type.lower() == "savings")
|
| 82 |
+
checking_balance = sum(acc.balance for acc in accounts if acc.type.lower() == "checking")
|
| 83 |
+
|
| 84 |
+
investments = db.query(Investment).filter(Investment.user_id == user_id).all()
|
| 85 |
+
total_invested = sum(inv.current_value for inv in investments)
|
| 86 |
+
|
| 87 |
+
# Subscriptions and recurring bills
|
| 88 |
+
subs = db.query(Subscription).filter(Subscription.user_id == user_id, Subscription.active == True).all()
|
| 89 |
+
monthly_sub_cost = sum(sub.amount if sub.billing_cycle.lower() == "monthly" else (sub.amount / 12) for sub in subs)
|
| 90 |
+
|
| 91 |
+
# Let's assume standard default rates if not specified
|
| 92 |
+
savings_apr = 0.04 # 4% APY
|
| 93 |
+
investment_apr = 0.08 # 8% APY
|
| 94 |
+
|
| 95 |
+
# We assume the user saves 10% of their net income monthly (derived from transaction history)
|
| 96 |
+
_, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 97 |
+
monthly_income = daily_income * 30.4
|
| 98 |
+
monthly_spending = daily_spending * 30.4
|
| 99 |
+
net_monthly = max(0.0, monthly_income - monthly_spending)
|
| 100 |
+
monthly_savings_addition = net_monthly * 0.5 # Put 50% of net into savings
|
| 101 |
+
monthly_investment_addition = net_monthly * 0.3 # Put 30% of net into investments
|
| 102 |
+
|
| 103 |
+
savings_data = []
|
| 104 |
+
investment_data = []
|
| 105 |
+
debt_data = []
|
| 106 |
+
|
| 107 |
+
current_savings = savings_balance
|
| 108 |
+
current_inv = total_invested
|
| 109 |
+
|
| 110 |
+
# Let's model a baseline debt if the user has a Goal of type "debt" or a general dummy debt
|
| 111 |
+
# We will look for Goals containing "debt" or "loan"
|
| 112 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 113 |
+
debt_goal = next((g for g in goals if "debt" in g.title.lower() or "loan" in g.title.lower()), None)
|
| 114 |
+
|
| 115 |
+
total_debt = 5000.0 # Default initial simulated debt if none found
|
| 116 |
+
if debt_goal:
|
| 117 |
+
total_debt = max(0.0, debt_goal.target_amount - debt_goal.current_amount)
|
| 118 |
+
|
| 119 |
+
monthly_debt_payment = min(total_debt, max(150.0, net_monthly * 0.1)) # Assume 10% of net or at least $150
|
| 120 |
+
|
| 121 |
+
for month in range(0, projection_months + 1):
|
| 122 |
+
# Compounding savings
|
| 123 |
+
if month > 0:
|
| 124 |
+
current_savings = (current_savings + monthly_savings_addition) * (1 + savings_apr / 12)
|
| 125 |
+
current_inv = (current_inv + monthly_investment_addition) * (1 + investment_apr / 12)
|
| 126 |
+
total_debt = max(0.0, total_debt - monthly_debt_payment)
|
| 127 |
+
|
| 128 |
+
label = f"Month {month}"
|
| 129 |
+
savings_data.append({"month": label, "amount": round(current_savings, 2)})
|
| 130 |
+
investment_data.append({"month": label, "amount": round(current_inv, 2)})
|
| 131 |
+
debt_data.append({"month": label, "amount": round(total_debt, 2)})
|
| 132 |
+
|
| 133 |
+
return {
|
| 134 |
+
"projection_months": projection_months,
|
| 135 |
+
"monthly_savings_addition": round(monthly_savings_addition, 2),
|
| 136 |
+
"monthly_investment_addition": round(monthly_investment_addition, 2),
|
| 137 |
+
"savings_growth": savings_data,
|
| 138 |
+
"investment_growth": investment_data,
|
| 139 |
+
"debt_decline": debt_data,
|
| 140 |
+
"total_projected_savings": round(current_savings, 2),
|
| 141 |
+
"total_projected_investments": round(current_inv, 2),
|
| 142 |
+
"total_remaining_debt": round(total_debt, 2)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
def simulate_future_scenarios(db: Session, user_id: str, projection_months: int = 6):
|
| 146 |
+
"""
|
| 147 |
+
Simulates three scenarios: Status Quo, Frugal (cut spending 20%), and Luxury (increase spending 15%).
|
| 148 |
+
"""
|
| 149 |
+
current_balance, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 150 |
+
monthly_income = daily_income * 30.4
|
| 151 |
+
monthly_spending = daily_spending * 30.4
|
| 152 |
+
|
| 153 |
+
scenarios = {
|
| 154 |
+
"status_quo": {"spend_mult": 1.0, "name": "Status Quo (Current spending)"},
|
| 155 |
+
"frugal": {"spend_mult": 0.8, "name": "Frugal Mode (Cut non-essentials by 20%)"},
|
| 156 |
+
"lifestyle_inflation": {"spend_mult": 1.15, "name": "Lifestyle Inflation (+15% spending)"}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
results = {}
|
| 160 |
+
for key, config in scenarios.items():
|
| 161 |
+
mult = config["spend_mult"]
|
| 162 |
+
projected_spend = monthly_spending * mult
|
| 163 |
+
net_monthly = monthly_income - projected_spend
|
| 164 |
+
|
| 165 |
+
balance_trend = []
|
| 166 |
+
balance = current_balance
|
| 167 |
+
for m in range(0, projection_months + 1):
|
| 168 |
+
if m > 0:
|
| 169 |
+
balance = max(0.0, balance + net_monthly)
|
| 170 |
+
balance_trend.append({"month": f"M{m}", "balance": round(balance, 2)})
|
| 171 |
+
|
| 172 |
+
results[key] = {
|
| 173 |
+
"name": config["name"],
|
| 174 |
+
"monthly_income": round(monthly_income, 2),
|
| 175 |
+
"monthly_spending": round(projected_spend, 2),
|
| 176 |
+
"net_monthly": round(net_monthly, 2),
|
| 177 |
+
"balance_projection": balance_trend,
|
| 178 |
+
"final_balance": round(balance, 2),
|
| 179 |
+
"savings_change_pct": round(((balance - current_balance) / current_balance * 100) if current_balance > 0 else 0.0, 2)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return results
|
backend/app/ai/fraud.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.database.models import Transaction, FraudLog, Account, User
|
| 5 |
+
|
| 6 |
+
def evaluate_transaction_for_fraud(db: Session, transaction_id: str):
|
| 7 |
+
"""
|
| 8 |
+
Evaluates a transaction for anomalies, generates a score, and logs alerts.
|
| 9 |
+
"""
|
| 10 |
+
txn = db.query(Transaction).filter(Transaction.id == transaction_id).first()
|
| 11 |
+
if not txn:
|
| 12 |
+
return {"error": "Transaction not found"}
|
| 13 |
+
|
| 14 |
+
account = db.query(Account).filter(Account.id == txn.account_id).first()
|
| 15 |
+
if not account:
|
| 16 |
+
return {"error": "Account not found for transaction"}
|
| 17 |
+
|
| 18 |
+
user_id = account.user_id
|
| 19 |
+
|
| 20 |
+
# Fetch historical transactions to compare
|
| 21 |
+
history = db.query(Transaction).join(Account).filter(
|
| 22 |
+
Account.user_id == user_id,
|
| 23 |
+
Transaction.type == "debit",
|
| 24 |
+
Transaction.id != transaction_id
|
| 25 |
+
).order_by(Transaction.timestamp.desc()).limit(30).all()
|
| 26 |
+
|
| 27 |
+
score = 0
|
| 28 |
+
reasons = []
|
| 29 |
+
|
| 30 |
+
# 1. Spikes in amount
|
| 31 |
+
if history:
|
| 32 |
+
amounts = [h.amount for h in history]
|
| 33 |
+
avg_amount = np.mean(amounts)
|
| 34 |
+
std_amount = np.std(amounts) if len(amounts) > 1 else 0.0
|
| 35 |
+
|
| 36 |
+
if txn.amount > avg_amount * 3.5:
|
| 37 |
+
score += 40
|
| 38 |
+
reasons.append(f"Transaction amount (${txn.amount:,.2f}) is abnormally high compared to your historical average of ${avg_amount:,.2f}.")
|
| 39 |
+
elif txn.amount > avg_amount * 2.0:
|
| 40 |
+
score += 20
|
| 41 |
+
reasons.append(f"Transaction amount is significantly higher than usual (2x historical average).")
|
| 42 |
+
else:
|
| 43 |
+
avg_amount = 0.0
|
| 44 |
+
|
| 45 |
+
# 2. Timing anomaly (Late night 11 PM - 4 AM)
|
| 46 |
+
hour = txn.timestamp.hour
|
| 47 |
+
if hour >= 23 or hour < 4:
|
| 48 |
+
score += 25
|
| 49 |
+
reasons.append("Unusual timing (transaction placed between 11 PM and 4 AM).")
|
| 50 |
+
|
| 51 |
+
# 3. Frequency anomaly (rapid consecutive transactions)
|
| 52 |
+
if history:
|
| 53 |
+
latest_txn = history[0]
|
| 54 |
+
time_diff = abs((txn.timestamp - latest_txn.timestamp).total_seconds())
|
| 55 |
+
if time_diff < 180: # Less than 3 minutes
|
| 56 |
+
score += 20
|
| 57 |
+
reasons.append("High-frequency activity: multiple transactions placed within 3 minutes.")
|
| 58 |
+
|
| 59 |
+
# 4. Duplicate transaction check (same merchant and amount within 10 minutes)
|
| 60 |
+
if history:
|
| 61 |
+
for prev in history[:5]:
|
| 62 |
+
time_diff = abs((txn.timestamp - prev.timestamp).total_seconds())
|
| 63 |
+
if prev.merchant == txn.merchant and prev.amount == txn.amount and time_diff < 600:
|
| 64 |
+
score += 30
|
| 65 |
+
reasons.append(f"Potential duplicate payment: identical debit of ${txn.amount:.2f} at {txn.merchant} detected within 10 minutes.")
|
| 66 |
+
break
|
| 67 |
+
|
| 68 |
+
# Normalize score to 100 max
|
| 69 |
+
score = min(100, score)
|
| 70 |
+
|
| 71 |
+
# Log to DB if score exceeds threshold
|
| 72 |
+
if score >= 30:
|
| 73 |
+
# Check if fraud log already exists
|
| 74 |
+
existing_log = db.query(FraudLog).filter(FraudLog.transaction_id == txn.id).first()
|
| 75 |
+
if not existing_log:
|
| 76 |
+
fraud_log = FraudLog(
|
| 77 |
+
transaction_id=txn.id,
|
| 78 |
+
risk_score=score / 100.0,
|
| 79 |
+
suspicious_activity_details="; ".join(reasons),
|
| 80 |
+
status="pending"
|
| 81 |
+
)
|
| 82 |
+
db.add(fraud_log)
|
| 83 |
+
db.commit()
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
"transaction_id": txn.id,
|
| 87 |
+
"amount": txn.amount,
|
| 88 |
+
"merchant": txn.merchant,
|
| 89 |
+
"timestamp": txn.timestamp.isoformat(),
|
| 90 |
+
"fraud_risk_score": score,
|
| 91 |
+
"is_anomalous": score >= 30,
|
| 92 |
+
"explanations": reasons,
|
| 93 |
+
"status": "flagged" if score >= 50 else "suspicious" if score >= 30 else "verified"
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def get_user_fraud_alerts(db: Session, user_id: str):
|
| 97 |
+
"""
|
| 98 |
+
Retrieves all flagged/suspicious transaction records and logs.
|
| 99 |
+
"""
|
| 100 |
+
logs = db.query(FraudLog).join(Transaction).join(Account).filter(
|
| 101 |
+
Account.user_id == user_id
|
| 102 |
+
).order_by(Transaction.timestamp.desc()).all()
|
| 103 |
+
|
| 104 |
+
alerts = []
|
| 105 |
+
for log in logs:
|
| 106 |
+
txn = log.transaction
|
| 107 |
+
alerts.append({
|
| 108 |
+
"fraud_log_id": log.id,
|
| 109 |
+
"transaction_id": txn.id,
|
| 110 |
+
"amount": txn.amount,
|
| 111 |
+
"merchant": txn.merchant,
|
| 112 |
+
"category": txn.category,
|
| 113 |
+
"timestamp": txn.timestamp.isoformat(),
|
| 114 |
+
"risk_score": round(log.risk_score * 100, 0),
|
| 115 |
+
"details": log.suspicious_activity_details,
|
| 116 |
+
"status": log.status
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"total_alerts": len(alerts),
|
| 121 |
+
"pending_reviews": sum(1 for a in alerts if a["status"] == "pending"),
|
| 122 |
+
"alerts": alerts
|
| 123 |
+
}
|
backend/app/ai/fraud_detection.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Fraud Detection System for BankBot
|
| 3 |
+
Uses machine learning to detect suspicious transactions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from sklearn.ensemble import IsolationForest, RandomForestClassifier
|
| 12 |
+
from sklearn.preprocessing import StandardScaler
|
| 13 |
+
import pickle
|
| 14 |
+
import uuid
|
| 15 |
+
|
| 16 |
+
FRAUD_ALERTS_FILE = "fraud_alerts.json"
|
| 17 |
+
FRAUD_MODEL_FILE = "fraud_model.pkl"
|
| 18 |
+
|
| 19 |
+
class FraudDetectionEngine:
|
| 20 |
+
"""Advanced fraud detection using multiple ML algorithms"""
|
| 21 |
+
|
| 22 |
+
def __init__(self):
|
| 23 |
+
self.isolation_forest = None
|
| 24 |
+
self.scaler = StandardScaler()
|
| 25 |
+
self.load_model()
|
| 26 |
+
|
| 27 |
+
def load_model(self):
|
| 28 |
+
"""Load saved model or create new one"""
|
| 29 |
+
if os.path.exists(FRAUD_MODEL_FILE):
|
| 30 |
+
try:
|
| 31 |
+
with open(FRAUD_MODEL_FILE, "rb") as f:
|
| 32 |
+
model_data = pickle.load(f)
|
| 33 |
+
self.isolation_forest = model_data.get("model")
|
| 34 |
+
self.scaler = model_data.get("scaler", StandardScaler())
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"Error loading fraud model: {e}")
|
| 37 |
+
self._initialize_model()
|
| 38 |
+
else:
|
| 39 |
+
self._initialize_model()
|
| 40 |
+
|
| 41 |
+
def _initialize_model(self):
|
| 42 |
+
"""Initialize Isolation Forest for anomaly detection"""
|
| 43 |
+
self.isolation_forest = IsolationForest(
|
| 44 |
+
contamination=0.1, # Expect ~10% anomalies
|
| 45 |
+
random_state=42,
|
| 46 |
+
n_estimators=100
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
def save_model(self):
|
| 50 |
+
"""Save trained model to disk"""
|
| 51 |
+
try:
|
| 52 |
+
with open(FRAUD_MODEL_FILE, "wb") as f:
|
| 53 |
+
pickle.dump({
|
| 54 |
+
"model": self.isolation_forest,
|
| 55 |
+
"scaler": self.scaler
|
| 56 |
+
}, f)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"Error saving fraud model: {e}")
|
| 59 |
+
|
| 60 |
+
def extract_features(self, transactions):
|
| 61 |
+
"""
|
| 62 |
+
Extract numerical features from transaction history
|
| 63 |
+
Returns: DataFrame with features for ML model
|
| 64 |
+
"""
|
| 65 |
+
if not transactions or len(transactions) < 2:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
df = pd.DataFrame(transactions)
|
| 69 |
+
|
| 70 |
+
# Convert date strings to datetime
|
| 71 |
+
df['date'] = pd.to_datetime(df['date'], errors='coerce')
|
| 72 |
+
|
| 73 |
+
features = []
|
| 74 |
+
for txn in transactions:
|
| 75 |
+
try:
|
| 76 |
+
amount = float(txn.get('amount', 0))
|
| 77 |
+
txn_type = 1 if txn.get('type') == 'debit' else 0
|
| 78 |
+
|
| 79 |
+
feature_dict = {
|
| 80 |
+
'amount': amount,
|
| 81 |
+
'type': txn_type,
|
| 82 |
+
'hour': datetime.fromisoformat(txn.get('date', '')).hour if txn.get('date') else 12,
|
| 83 |
+
'day_of_week': datetime.fromisoformat(txn.get('date', '')).weekday() if txn.get('date') else 3,
|
| 84 |
+
}
|
| 85 |
+
features.append(feature_dict)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"Error extracting features: {e}")
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
return pd.DataFrame(features) if features else None
|
| 91 |
+
|
| 92 |
+
def detect_anomalies(self, transactions):
|
| 93 |
+
"""
|
| 94 |
+
Detect anomalous transactions using Isolation Forest
|
| 95 |
+
Returns: List of anomaly indices and scores
|
| 96 |
+
"""
|
| 97 |
+
if not transactions or len(transactions) < 2:
|
| 98 |
+
return [], []
|
| 99 |
+
|
| 100 |
+
features_df = self.extract_features(transactions)
|
| 101 |
+
if features_df is None or len(features_df) < 2:
|
| 102 |
+
return [], []
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
# Normalize features
|
| 106 |
+
X = self.scaler.fit_transform(features_df)
|
| 107 |
+
|
| 108 |
+
# Detect anomalies (-1 = anomaly, 1 = normal)
|
| 109 |
+
predictions = self.isolation_forest.predict(X)
|
| 110 |
+
anomaly_scores = self.isolation_forest.score_samples(X)
|
| 111 |
+
|
| 112 |
+
# Find anomalies
|
| 113 |
+
anomalies = np.where(predictions == -1)[0].tolist()
|
| 114 |
+
|
| 115 |
+
return anomalies, anomaly_scores
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Error in anomaly detection: {e}")
|
| 118 |
+
return [], []
|
| 119 |
+
|
| 120 |
+
def calculate_fraud_score(self, transaction, user_history):
|
| 121 |
+
"""
|
| 122 |
+
Calculate fraud probability for a single transaction (0-100)
|
| 123 |
+
Considers: amount, frequency, location, time patterns
|
| 124 |
+
"""
|
| 125 |
+
score = 0
|
| 126 |
+
reasons = []
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
amount = float(transaction.get('amount', 0))
|
| 130 |
+
|
| 131 |
+
# Rule 1: Large withdrawal
|
| 132 |
+
avg_transaction = np.mean([float(t.get('amount', 0))
|
| 133 |
+
for t in user_history[-20:] if t.get('type') == 'debit'])
|
| 134 |
+
if avg_transaction > 0 and amount > avg_transaction * 3:
|
| 135 |
+
score += 25
|
| 136 |
+
reasons.append("Unusually large transaction")
|
| 137 |
+
|
| 138 |
+
# Rule 2: Rapid consecutive transactions (within 5 minutes)
|
| 139 |
+
if len(user_history) > 1:
|
| 140 |
+
last_txn_time = datetime.fromisoformat(user_history[0].get('date', ''))
|
| 141 |
+
current_time = datetime.fromisoformat(transaction.get('date', ''))
|
| 142 |
+
if (current_time - last_txn_time).total_seconds() < 300:
|
| 143 |
+
score += 20
|
| 144 |
+
reasons.append("Rapid consecutive transactions")
|
| 145 |
+
|
| 146 |
+
# Rule 3: Late night transaction (11 PM - 4 AM)
|
| 147 |
+
try:
|
| 148 |
+
hour = datetime.fromisoformat(transaction.get('date', '')).hour
|
| 149 |
+
if hour >= 23 or hour < 4:
|
| 150 |
+
score += 15
|
| 151 |
+
reasons.append("Unusual time of transaction")
|
| 152 |
+
except:
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
# Rule 4: Weekend large transaction
|
| 156 |
+
try:
|
| 157 |
+
day = datetime.fromisoformat(transaction.get('date', '')).weekday()
|
| 158 |
+
if day >= 5 and amount > avg_transaction * 2: # Saturday/Sunday
|
| 159 |
+
score += 10
|
| 160 |
+
reasons.append("Weekend large transaction")
|
| 161 |
+
except:
|
| 162 |
+
pass
|
| 163 |
+
|
| 164 |
+
# Rule 5: Debit after multiple recent debits
|
| 165 |
+
debit_count = sum(1 for t in user_history[-5:] if t.get('type') == 'debit')
|
| 166 |
+
if debit_count >= 4:
|
| 167 |
+
score += 15
|
| 168 |
+
reasons.append("Multiple recent debits")
|
| 169 |
+
|
| 170 |
+
# Cap score at 100
|
| 171 |
+
score = min(score, 100)
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f"Error calculating fraud score: {e}")
|
| 175 |
+
|
| 176 |
+
return score, reasons
|
| 177 |
+
|
| 178 |
+
def check_fraud_alerts(username, users_data):
|
| 179 |
+
"""
|
| 180 |
+
Check for fraud alerts for a user
|
| 181 |
+
Returns: List of fraud alerts
|
| 182 |
+
"""
|
| 183 |
+
user_data = users_data.get(username, {})
|
| 184 |
+
transactions = user_data.get('transactions', [])
|
| 185 |
+
|
| 186 |
+
if not transactions:
|
| 187 |
+
return []
|
| 188 |
+
|
| 189 |
+
detector = FraudDetectionEngine()
|
| 190 |
+
alerts = []
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
# Analyze recent transactions (last 10)
|
| 194 |
+
recent_txns = transactions[:10]
|
| 195 |
+
anomalies, scores = detector.detect_anomalies(recent_txns)
|
| 196 |
+
|
| 197 |
+
# Create alerts for anomalies
|
| 198 |
+
for idx in anomalies:
|
| 199 |
+
if idx < len(recent_txns):
|
| 200 |
+
txn = recent_txns[idx]
|
| 201 |
+
fraud_score, reasons = detector.calculate_fraud_score(txn, recent_txns)
|
| 202 |
+
|
| 203 |
+
if fraud_score > 30: # Alert threshold
|
| 204 |
+
alert = {
|
| 205 |
+
"id": str(uuid.uuid4()),
|
| 206 |
+
"transaction_id": txn.get('id'),
|
| 207 |
+
"amount": txn.get('amount'),
|
| 208 |
+
"fraud_score": fraud_score,
|
| 209 |
+
"reasons": reasons,
|
| 210 |
+
"timestamp": datetime.now().isoformat(),
|
| 211 |
+
"status": "active"
|
| 212 |
+
}
|
| 213 |
+
alerts.append(alert)
|
| 214 |
+
|
| 215 |
+
except Exception as e:
|
| 216 |
+
print(f"Error checking fraud alerts: {e}")
|
| 217 |
+
|
| 218 |
+
return alerts
|
| 219 |
+
|
| 220 |
+
def get_fraud_alerts_summary(username, users_data):
|
| 221 |
+
"""Get summary of fraud alerts for a user"""
|
| 222 |
+
alerts = check_fraud_alerts(username, users_data)
|
| 223 |
+
|
| 224 |
+
high_risk = sum(1 for a in alerts if a.get('fraud_score', 0) > 70)
|
| 225 |
+
medium_risk = sum(1 for a in alerts if 30 < a.get('fraud_score', 0) <= 70)
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
"total_alerts": len(alerts),
|
| 229 |
+
"high_risk": high_risk,
|
| 230 |
+
"medium_risk": medium_risk,
|
| 231 |
+
"alerts": alerts[:5] # Return latest 5
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
def generate_fraud_report(username, users_data, days=30):
|
| 235 |
+
"""Generate a comprehensive fraud analysis report"""
|
| 236 |
+
user_data = users_data.get(username, {})
|
| 237 |
+
transactions = user_data.get('transactions', [])
|
| 238 |
+
|
| 239 |
+
if not transactions:
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
# Filter transactions from last N days
|
| 243 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 244 |
+
recent_txns = [t for t in transactions
|
| 245 |
+
if datetime.fromisoformat(t.get('date', '')) > cutoff_date]
|
| 246 |
+
|
| 247 |
+
detector = FraudDetectionEngine()
|
| 248 |
+
|
| 249 |
+
# Calculate statistics
|
| 250 |
+
total_transactions = len(recent_txns)
|
| 251 |
+
total_debit = sum(float(t.get('amount', 0)) for t in recent_txns if t.get('type') == 'debit')
|
| 252 |
+
avg_transaction = total_debit / len([t for t in recent_txns if t.get('type') == 'debit']) if any(t.get('type') == 'debit' for t in recent_txns) else 0
|
| 253 |
+
|
| 254 |
+
# Run anomaly detection
|
| 255 |
+
anomalies, _ = detector.detect_anomalies(recent_txns)
|
| 256 |
+
|
| 257 |
+
report = {
|
| 258 |
+
"period_days": days,
|
| 259 |
+
"total_transactions": total_transactions,
|
| 260 |
+
"total_debit_amount": total_debit,
|
| 261 |
+
"average_transaction": round(avg_transaction, 2),
|
| 262 |
+
"anomalies_detected": len(anomalies),
|
| 263 |
+
"risk_level": "HIGH" if len(anomalies) > total_transactions * 0.15 else "MEDIUM" if len(anomalies) > total_transactions * 0.05 else "LOW",
|
| 264 |
+
"alerts": check_fraud_alerts(username, users_data),
|
| 265 |
+
"recommendations": generate_fraud_recommendations(username, users_data)
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
return report
|
| 269 |
+
|
| 270 |
+
def generate_fraud_recommendations(username, users_data):
|
| 271 |
+
"""Generate recommendations based on fraud analysis"""
|
| 272 |
+
alerts = check_fraud_alerts(username, users_data)
|
| 273 |
+
recommendations = []
|
| 274 |
+
|
| 275 |
+
if not alerts:
|
| 276 |
+
recommendations.append("β
No suspicious activities detected. Your account is secure.")
|
| 277 |
+
else:
|
| 278 |
+
high_risk_count = sum(1 for a in alerts if a.get('fraud_score', 0) > 70)
|
| 279 |
+
if high_risk_count > 0:
|
| 280 |
+
recommendations.append(f"β οΈ {high_risk_count} high-risk transactions detected. Please verify them immediately.")
|
| 281 |
+
|
| 282 |
+
recommendations.append("π‘ Enable transaction alerts for amounts above βΉ5,000")
|
| 283 |
+
recommendations.append("π Review and update your password regularly")
|
| 284 |
+
recommendations.append("π± Use 2FA for additional security")
|
| 285 |
+
|
| 286 |
+
return recommendations
|
backend/app/ai/loan_prediction_model.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:17a92ade8661126d286434ab96256939040736511018f552b2a2b78156a5377f
|
| 3 |
+
size 55529
|
backend/app/ai/loan_predictor.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Loan Eligibility Predictor for BankBot
|
| 3 |
+
Predicts loan approval chance and EMI affordability using ML
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from sklearn.preprocessing import StandardScaler
|
| 9 |
+
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
|
| 10 |
+
import pickle
|
| 11 |
+
import os
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
LOAN_MODEL_FILE = "loan_prediction_model.pkl"
|
| 16 |
+
|
| 17 |
+
class LoanEligibilityPredictor:
|
| 18 |
+
"""ML-based loan eligibility prediction"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.classifier = None
|
| 22 |
+
self.scaler = StandardScaler()
|
| 23 |
+
self.feature_names = [
|
| 24 |
+
'salary', 'credit_score', 'existing_loans',
|
| 25 |
+
'employment_years', 'age', 'loan_amount'
|
| 26 |
+
]
|
| 27 |
+
self.load_model()
|
| 28 |
+
|
| 29 |
+
def load_model(self):
|
| 30 |
+
"""Load saved model or create new one"""
|
| 31 |
+
if os.path.exists(LOAN_MODEL_FILE):
|
| 32 |
+
try:
|
| 33 |
+
with open(LOAN_MODEL_FILE, "rb") as f:
|
| 34 |
+
model_data = pickle.load(f)
|
| 35 |
+
self.classifier = model_data.get("classifier")
|
| 36 |
+
self.scaler = model_data.get("scaler", StandardScaler())
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"Error loading loan model: {e}")
|
| 39 |
+
self._initialize_model()
|
| 40 |
+
else:
|
| 41 |
+
self._initialize_model()
|
| 42 |
+
|
| 43 |
+
def _initialize_model(self):
|
| 44 |
+
"""Initialize Random Forest for loan prediction"""
|
| 45 |
+
# Create synthetic training data
|
| 46 |
+
X_train = np.array([
|
| 47 |
+
[100000, 750, 0, 5, 35, 500000], # Approved
|
| 48 |
+
[150000, 800, 1, 10, 42, 1000000], # Approved
|
| 49 |
+
[200000, 780, 2, 8, 45, 1500000], # Approved
|
| 50 |
+
[50000, 600, 3, 2, 28, 300000], # Rejected
|
| 51 |
+
[80000, 650, 2, 3, 32, 400000], # Rejected
|
| 52 |
+
[120000, 700, 1, 6, 38, 600000], # Approved
|
| 53 |
+
[45000, 580, 4, 1, 25, 250000], # Rejected
|
| 54 |
+
[180000, 770, 0, 12, 50, 900000], # Approved
|
| 55 |
+
[70000, 620, 3, 2, 30, 350000], # Rejected
|
| 56 |
+
[160000, 790, 1, 9, 44, 800000], # Approved
|
| 57 |
+
])
|
| 58 |
+
|
| 59 |
+
y_train = np.array([1, 1, 1, 0, 0, 1, 0, 1, 0, 1]) # 1=Approved, 0=Rejected
|
| 60 |
+
|
| 61 |
+
# Normalize features
|
| 62 |
+
X_train_scaled = self.scaler.fit_transform(X_train)
|
| 63 |
+
|
| 64 |
+
# Train classifier
|
| 65 |
+
self.classifier = RandomForestClassifier(n_estimators=100, random_state=42)
|
| 66 |
+
self.classifier.fit(X_train_scaled, y_train)
|
| 67 |
+
|
| 68 |
+
self.save_model()
|
| 69 |
+
|
| 70 |
+
def save_model(self):
|
| 71 |
+
"""Save trained model to disk"""
|
| 72 |
+
try:
|
| 73 |
+
with open(LOAN_MODEL_FILE, "wb") as f:
|
| 74 |
+
pickle.dump({
|
| 75 |
+
"classifier": self.classifier,
|
| 76 |
+
"scaler": self.scaler
|
| 77 |
+
}, f)
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"Error saving loan model: {e}")
|
| 80 |
+
|
| 81 |
+
def predict_eligibility(self, salary, credit_score, existing_loans,
|
| 82 |
+
employment_years, age, loan_amount):
|
| 83 |
+
"""
|
| 84 |
+
Predict loan eligibility
|
| 85 |
+
Returns: Approval probability (0-100), risk level, recommendations
|
| 86 |
+
"""
|
| 87 |
+
try:
|
| 88 |
+
# Prepare features
|
| 89 |
+
features = np.array([[
|
| 90 |
+
salary, credit_score, existing_loans,
|
| 91 |
+
employment_years, age, loan_amount
|
| 92 |
+
]])
|
| 93 |
+
|
| 94 |
+
# Normalize
|
| 95 |
+
features_scaled = self.scaler.transform(features)
|
| 96 |
+
|
| 97 |
+
# Predict probability
|
| 98 |
+
approval_prob = self.classifier.predict_proba(features_scaled)[0][1] * 100
|
| 99 |
+
|
| 100 |
+
# Calculate risk level
|
| 101 |
+
if approval_prob >= 80:
|
| 102 |
+
risk_level = "LOW RISK β
"
|
| 103 |
+
elif approval_prob >= 60:
|
| 104 |
+
risk_level = "MEDIUM RISK β οΈ"
|
| 105 |
+
elif approval_prob >= 40:
|
| 106 |
+
risk_level = "HIGH RISK β"
|
| 107 |
+
else:
|
| 108 |
+
risk_level = "VERY HIGH RISK β"
|
| 109 |
+
|
| 110 |
+
return approval_prob, risk_level
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"Error in prediction: {e}")
|
| 114 |
+
return 50, "UNKNOWN RISK"
|
| 115 |
+
|
| 116 |
+
def check_eligibility_rules(self, salary, credit_score, existing_loans,
|
| 117 |
+
employment_years, age, loan_amount):
|
| 118 |
+
"""
|
| 119 |
+
Check basic eligibility rules
|
| 120 |
+
Returns: Boolean and list of issues
|
| 121 |
+
"""
|
| 122 |
+
issues = []
|
| 123 |
+
|
| 124 |
+
# Age check
|
| 125 |
+
if age < 21:
|
| 126 |
+
issues.append("Age must be at least 21 years")
|
| 127 |
+
if age > 65:
|
| 128 |
+
issues.append("Age exceeds maximum limit (65 years)")
|
| 129 |
+
|
| 130 |
+
# Employment check
|
| 131 |
+
if employment_years < 1:
|
| 132 |
+
issues.append("Minimum 1 year employment required")
|
| 133 |
+
|
| 134 |
+
# Credit score check
|
| 135 |
+
if credit_score < 600:
|
| 136 |
+
issues.append("Credit score too low (minimum 600 required)")
|
| 137 |
+
|
| 138 |
+
# Salary check
|
| 139 |
+
if salary < 25000:
|
| 140 |
+
issues.append("Salary too low for loan eligibility")
|
| 141 |
+
|
| 142 |
+
# Loan amount vs salary ratio
|
| 143 |
+
emi_amount = calculate_emi(loan_amount, 12, 10) # Assume 12% rate, 10 years
|
| 144 |
+
if (emi_amount / salary) > 0.5: # EMI shouldn't exceed 50% of salary
|
| 145 |
+
issues.append(f"EMI of βΉ{emi_amount:.2f} exceeds 50% of salary")
|
| 146 |
+
|
| 147 |
+
# Existing loans check
|
| 148 |
+
if existing_loans > 3:
|
| 149 |
+
issues.append("Too many existing loans")
|
| 150 |
+
|
| 151 |
+
is_eligible = len(issues) == 0
|
| 152 |
+
return is_eligible, issues
|
| 153 |
+
|
| 154 |
+
def calculate_loan_score(self, salary, credit_score, existing_loans,
|
| 155 |
+
employment_years, age, loan_amount):
|
| 156 |
+
"""
|
| 157 |
+
Calculate comprehensive loan score (0-100)
|
| 158 |
+
Considers multiple factors
|
| 159 |
+
"""
|
| 160 |
+
score = 0
|
| 161 |
+
|
| 162 |
+
# Credit score weight (40%)
|
| 163 |
+
credit_component = (min(credit_score, 850) / 850) * 40
|
| 164 |
+
score += credit_component
|
| 165 |
+
|
| 166 |
+
# Salary weight (30%)
|
| 167 |
+
salary_component = min((salary / 500000) * 30, 30)
|
| 168 |
+
score += salary_component
|
| 169 |
+
|
| 170 |
+
# Employment years weight (15%)
|
| 171 |
+
employment_component = min((employment_years / 30) * 15, 15)
|
| 172 |
+
score += employment_component
|
| 173 |
+
|
| 174 |
+
# Existing loans weight (10%) - negative impact
|
| 175 |
+
loan_penalty = min(existing_loans * 2, 10)
|
| 176 |
+
score -= loan_penalty
|
| 177 |
+
|
| 178 |
+
# Age factor (5%) - younger is better
|
| 179 |
+
age_component = min(((65 - age) / 45) * 5, 5)
|
| 180 |
+
score += age_component
|
| 181 |
+
|
| 182 |
+
# Loan affordability (penalties if high)
|
| 183 |
+
emi = calculate_emi(loan_amount, 12, 10)
|
| 184 |
+
if (emi / salary) > 0.5:
|
| 185 |
+
score -= 15
|
| 186 |
+
elif (emi / salary) > 0.4:
|
| 187 |
+
score -= 10
|
| 188 |
+
|
| 189 |
+
return max(0, min(score, 100))
|
| 190 |
+
|
| 191 |
+
def calculate_emi(principal, rate_per_annum=10, years=10):
|
| 192 |
+
"""
|
| 193 |
+
Calculate EMI (Equated Monthly Installment)
|
| 194 |
+
Formula: EMI = P * r * (1+r)^n / ((1+r)^n - 1)
|
| 195 |
+
"""
|
| 196 |
+
monthly_rate = rate_per_annum / 100 / 12
|
| 197 |
+
months = years * 12
|
| 198 |
+
|
| 199 |
+
if monthly_rate == 0:
|
| 200 |
+
return principal / months
|
| 201 |
+
|
| 202 |
+
emi = principal * monthly_rate * ((1 + monthly_rate) ** months) / (
|
| 203 |
+
((1 + monthly_rate) ** months) - 1
|
| 204 |
+
)
|
| 205 |
+
return emi
|
| 206 |
+
|
| 207 |
+
def calculate_loan_eligibility(salary, credit_score, existing_loans,
|
| 208 |
+
employment_years, age, loan_amount):
|
| 209 |
+
"""Main function to calculate loan eligibility"""
|
| 210 |
+
predictor = LoanEligibilityPredictor()
|
| 211 |
+
|
| 212 |
+
# Check basic eligibility
|
| 213 |
+
is_eligible, issues = predictor.check_eligibility_rules(
|
| 214 |
+
salary, credit_score, existing_loans, employment_years, age, loan_amount
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Get ML prediction
|
| 218 |
+
approval_prob, risk_level = predictor.predict_eligibility(
|
| 219 |
+
salary, credit_score, existing_loans, employment_years, age, loan_amount
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Calculate loan score
|
| 223 |
+
loan_score = predictor.calculate_loan_score(
|
| 224 |
+
salary, credit_score, existing_loans, employment_years, age, loan_amount
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# Calculate EMI
|
| 228 |
+
emi = calculate_emi(loan_amount, 12, 10)
|
| 229 |
+
|
| 230 |
+
# Get recommendations
|
| 231 |
+
recommendations = get_loan_recommendations(
|
| 232 |
+
approval_prob, salary, credit_score, existing_loans, employment_years, emi
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
result = {
|
| 236 |
+
"approval_probability": round(approval_prob, 1),
|
| 237 |
+
"approval_status": "APPROVED β
" if approval_prob >= 60 else "REJECTED β" if approval_prob < 40 else "UNDER REVIEW β³",
|
| 238 |
+
"risk_level": risk_level,
|
| 239 |
+
"loan_score": round(loan_score, 1),
|
| 240 |
+
"is_rule_eligible": is_eligible,
|
| 241 |
+
"issues": issues,
|
| 242 |
+
"emi": round(emi, 2),
|
| 243 |
+
"total_amount": round(loan_amount + (emi * 12 * 10) - loan_amount, 2),
|
| 244 |
+
"monthly_emi": round(emi, 2),
|
| 245 |
+
"tenure_years": 10,
|
| 246 |
+
"rate_per_annum": 12,
|
| 247 |
+
"recommendations": recommendations
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return result
|
| 251 |
+
|
| 252 |
+
def get_loan_recommendations(approval_prob, salary, credit_score,
|
| 253 |
+
existing_loans, employment_years, emi):
|
| 254 |
+
"""Generate personalized loan recommendations"""
|
| 255 |
+
recommendations = []
|
| 256 |
+
|
| 257 |
+
if approval_prob >= 80:
|
| 258 |
+
recommendations.append("β
You are likely to get approved for this loan amount")
|
| 259 |
+
elif approval_prob < 40:
|
| 260 |
+
recommendations.append("β Your approval chances are low. Consider these options:")
|
| 261 |
+
|
| 262 |
+
if credit_score < 700:
|
| 263 |
+
recommendations.append(" β’ Improve your credit score to 700+")
|
| 264 |
+
|
| 265 |
+
if existing_loans > 2:
|
| 266 |
+
recommendations.append(" β’ Pay off existing loans to improve your profile")
|
| 267 |
+
|
| 268 |
+
recommendations.append(" β’ Apply for a smaller loan amount")
|
| 269 |
+
recommendations.append(" β’ Increase your employment tenure")
|
| 270 |
+
|
| 271 |
+
else:
|
| 272 |
+
recommendations.append("β³ Your application will be under review")
|
| 273 |
+
|
| 274 |
+
# EMI affordability
|
| 275 |
+
emi_ratio = (emi / salary) * 100
|
| 276 |
+
if emi_ratio > 50:
|
| 277 |
+
recommendations.append(f"β οΈ Your EMI (βΉ{emi:.2f}) is {emi_ratio:.1f}% of salary. Consider reducing loan amount.")
|
| 278 |
+
elif emi_ratio < 30:
|
| 279 |
+
recommendations.append(f"β
Your EMI to salary ratio ({emi_ratio:.1f}%) is very healthy")
|
| 280 |
+
|
| 281 |
+
return recommendations
|
| 282 |
+
|
| 283 |
+
def generate_loan_comparison(loan_amount, rates=[9, 10, 11, 12, 13], tenure_years=[5, 7, 10]):
|
| 284 |
+
"""Generate EMI comparison for different rates and tenures"""
|
| 285 |
+
comparison_data = []
|
| 286 |
+
|
| 287 |
+
for rate in rates:
|
| 288 |
+
for tenure in tenure_years:
|
| 289 |
+
emi = calculate_emi(loan_amount, rate, tenure)
|
| 290 |
+
total_amount = (emi * 12 * tenure)
|
| 291 |
+
interest = total_amount - loan_amount
|
| 292 |
+
|
| 293 |
+
comparison_data.append({
|
| 294 |
+
"rate": f"{rate}%",
|
| 295 |
+
"tenure": f"{tenure} years",
|
| 296 |
+
"emi": round(emi, 2),
|
| 297 |
+
"total_amount": round(total_amount, 2),
|
| 298 |
+
"interest": round(interest, 2)
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
return comparison_data
|
backend/app/ai/ollama_integration.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
# βββ Backend credentials (read once at module load) βββββββββββββββββββββββββββ
|
| 7 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 8 |
+
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
| 9 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 10 |
+
USE_GROQ = bool(GROQ_API_KEY)
|
| 11 |
+
|
| 12 |
+
OLLAMA_URL = "http://127.0.0.1:11434"
|
| 13 |
+
|
| 14 |
+
# Check active backends once at load time to prevent timeout delays during requests.
|
| 15 |
+
# Priority: OpenAI β Groq β local Ollama
|
| 16 |
+
AI_BACKEND_AVAILABLE = False
|
| 17 |
+
if OPENAI_API_KEY or GROQ_API_KEY:
|
| 18 |
+
AI_BACKEND_AVAILABLE = True
|
| 19 |
+
else:
|
| 20 |
+
try:
|
| 21 |
+
# Fast 0.5s ping to local Ollama
|
| 22 |
+
response = requests.get(f"{OLLAMA_URL}/", timeout=0.5)
|
| 23 |
+
AI_BACKEND_AVAILABLE = (response.status_code == 200)
|
| 24 |
+
except Exception:
|
| 25 |
+
AI_BACKEND_AVAILABLE = False
|
| 26 |
+
|
| 27 |
+
def has_active_ai_backend() -> bool:
|
| 28 |
+
"""Returns True if OpenAI, Groq, or local Ollama is active and reachable."""
|
| 29 |
+
return AI_BACKEND_AVAILABLE
|
| 30 |
+
|
| 31 |
+
BANKING_KEYWORDS = [
|
| 32 |
+
"account", "loan", "card", "balance",
|
| 33 |
+
"transfer", "bank", "interest", "emi",
|
| 34 |
+
"credit", "debit", "kyc", "upi", "cheque",
|
| 35 |
+
"deposit", "fd", "rd", "branch", "ifsc",
|
| 36 |
+
"transaction", "payment", "savings", "checking",
|
| 37 |
+
"mortgage", "investment", "fintech", "wallet",
|
| 38 |
+
"rate", "rates", "support", "customer", "care",
|
| 39 |
+
"help", "contact", "helpline", "number", "call",
|
| 40 |
+
"document", "required", "identity", "proof", "open"
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
SYSTEM_PROMPT = """You are BankBot, a professional banking assistant for Central Bank.
|
| 44 |
+
You ONLY answer banking-related questions. If the question is not related to banking, politely refuse.
|
| 45 |
+
Never answer questions about politics, sports, entertainment, coding, or personal advice.
|
| 46 |
+
|
| 47 |
+
CORE GUIDELINES:
|
| 48 |
+
1. ALWAYS communicate in {language}.
|
| 49 |
+
2. CONTEXT AWARENESS: Use the provided chat history to understand follow-up questions. For example, if the user asks "What is the interest rate?" and then "for home loan", you must understand they are asking about home loan interest rates.
|
| 50 |
+
3. CLARIFYING QUESTIONS: If a user's query is ambiguous (e.g., "how much?"), ask for missing details (e.g., "How much for what service? Balance check or loan EMI?").
|
| 51 |
+
4. CALCULATIONS: Perform financial calculations (EMI, Interest, Eligibility) if information is provided.
|
| 52 |
+
5. DOCUMENT ANALYSIS: If text from a PDF statement is provided, summarize it or answer specific questions about it.
|
| 53 |
+
6. PROFESSIONALISM: Maintain a helpful, formal, and secure tone."""
|
| 54 |
+
|
| 55 |
+
OLLAMA_URL = "http://127.0.0.1:11434"
|
| 56 |
+
DEFAULT_OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3:latest")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def is_banking_query(user_input):
|
| 60 |
+
input_lower = user_input.lower()
|
| 61 |
+
return any(word in input_lower for word in BANKING_KEYWORDS)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_active_backend():
|
| 65 |
+
"""Returns the highest-priority available backend name."""
|
| 66 |
+
if OPENAI_API_KEY:
|
| 67 |
+
return "openai"
|
| 68 |
+
if USE_GROQ:
|
| 69 |
+
return "groq"
|
| 70 |
+
return "ollama"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _build_messages(prompt, history=None, language="English"):
|
| 74 |
+
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 75 |
+
messages = [{"role": "system", "content": sys_prompt}]
|
| 76 |
+
|
| 77 |
+
if history:
|
| 78 |
+
for msg in history[-10:]:
|
| 79 |
+
if msg.get("role") and msg.get("content"):
|
| 80 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 81 |
+
|
| 82 |
+
messages.append({"role": "user", "content": prompt})
|
| 83 |
+
return messages
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _get_available_ollama_models():
|
| 87 |
+
try:
|
| 88 |
+
response = requests.get(f"{OLLAMA_URL}/api/tags", timeout=5)
|
| 89 |
+
response.raise_for_status()
|
| 90 |
+
data = response.json()
|
| 91 |
+
return [model.get("name", "") for model in data.get("models", []) if model.get("name")]
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"Ollama model discovery error: {e}")
|
| 94 |
+
return []
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _resolve_ollama_model(requested_model):
|
| 98 |
+
available_models = _get_available_ollama_models()
|
| 99 |
+
if not available_models:
|
| 100 |
+
return requested_model
|
| 101 |
+
|
| 102 |
+
if requested_model in available_models:
|
| 103 |
+
return requested_model
|
| 104 |
+
|
| 105 |
+
base_requested_model = requested_model.split(":", 1)[0]
|
| 106 |
+
for candidate in available_models:
|
| 107 |
+
if candidate.split(":", 1)[0] == base_requested_model:
|
| 108 |
+
return candidate
|
| 109 |
+
|
| 110 |
+
return available_models[0]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _ollama_error_message(model, error):
|
| 114 |
+
return (
|
| 115 |
+
f"Ollama request failed for model '{model}': {error}. "
|
| 116 |
+
"The Ollama server is reachable, but the model backend crashed internally. "
|
| 117 |
+
"Try `ollama run llama3`, and if that fails restart Ollama with "
|
| 118 |
+
"`taskkill /F /IM ollama.exe` followed by `ollama serve`."
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# βββ OpenAI Functions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
+
|
| 124 |
+
def get_openai_response(prompt, history=None, model=None, language="English"):
|
| 125 |
+
"""Fetches a response from the OpenAI API (gpt-4o-mini by default)."""
|
| 126 |
+
if not OPENAI_API_KEY:
|
| 127 |
+
return None
|
| 128 |
+
try:
|
| 129 |
+
from openai import OpenAI
|
| 130 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 131 |
+
target_model = model or OPENAI_MODEL
|
| 132 |
+
|
| 133 |
+
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 134 |
+
messages = [{"role": "system", "content": sys_prompt}]
|
| 135 |
+
|
| 136 |
+
if history:
|
| 137 |
+
for msg in history[-10:]:
|
| 138 |
+
if msg.get("role") and msg.get("content"):
|
| 139 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 140 |
+
|
| 141 |
+
messages.append({"role": "user", "content": prompt})
|
| 142 |
+
|
| 143 |
+
response = client.chat.completions.create(
|
| 144 |
+
model=target_model,
|
| 145 |
+
messages=messages,
|
| 146 |
+
temperature=0.1,
|
| 147 |
+
max_tokens=1000,
|
| 148 |
+
)
|
| 149 |
+
return response.choices[0].message.content
|
| 150 |
+
except Exception as e:
|
| 151 |
+
print(f"OpenAI Error: {e}")
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def stream_openai_response(prompt, history=None, model=None, language="English"):
|
| 156 |
+
"""Yields streamed response chunks from the OpenAI API."""
|
| 157 |
+
if not OPENAI_API_KEY:
|
| 158 |
+
return
|
| 159 |
+
try:
|
| 160 |
+
from openai import OpenAI
|
| 161 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 162 |
+
target_model = model or OPENAI_MODEL
|
| 163 |
+
|
| 164 |
+
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 165 |
+
messages = [{"role": "system", "content": sys_prompt}]
|
| 166 |
+
|
| 167 |
+
if history:
|
| 168 |
+
for msg in history[-10:]:
|
| 169 |
+
if msg.get("role") and msg.get("content"):
|
| 170 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 171 |
+
|
| 172 |
+
messages.append({"role": "user", "content": prompt})
|
| 173 |
+
|
| 174 |
+
stream = client.chat.completions.create(
|
| 175 |
+
model=target_model,
|
| 176 |
+
messages=messages,
|
| 177 |
+
temperature=0.1,
|
| 178 |
+
max_tokens=1000,
|
| 179 |
+
stream=True,
|
| 180 |
+
)
|
| 181 |
+
for chunk in stream:
|
| 182 |
+
content = chunk.choices[0].delta.content
|
| 183 |
+
if content:
|
| 184 |
+
yield content
|
| 185 |
+
except Exception as e:
|
| 186 |
+
print(f"OpenAI Stream Error: {e}")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# βββ Groq AI Functions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
|
| 191 |
+
def get_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", language="English"):
|
| 192 |
+
"""Fetches a response from Groq AI API."""
|
| 193 |
+
try:
|
| 194 |
+
from groq import Groq
|
| 195 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 196 |
+
|
| 197 |
+
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 198 |
+
messages = [{"role": "system", "content": sys_prompt}]
|
| 199 |
+
|
| 200 |
+
if history:
|
| 201 |
+
for msg in history[-10:]:
|
| 202 |
+
if msg.get("role") and msg.get("content"):
|
| 203 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 204 |
+
|
| 205 |
+
messages.append({"role": "user", "content": prompt})
|
| 206 |
+
|
| 207 |
+
response = client.chat.completions.create(
|
| 208 |
+
model=model,
|
| 209 |
+
messages=messages,
|
| 210 |
+
temperature=0.1,
|
| 211 |
+
max_tokens=1000,
|
| 212 |
+
)
|
| 213 |
+
return response.choices[0].message.content
|
| 214 |
+
except Exception as e:
|
| 215 |
+
print(f"Groq Error: {e}")
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def stream_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", language="English"):
|
| 220 |
+
"""Yields streamed response chunks from Groq AI API."""
|
| 221 |
+
try:
|
| 222 |
+
from groq import Groq
|
| 223 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 224 |
+
|
| 225 |
+
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 226 |
+
messages = [{"role": "system", "content": sys_prompt}]
|
| 227 |
+
|
| 228 |
+
if history:
|
| 229 |
+
for msg in history[-10:]:
|
| 230 |
+
if msg.get("role") and msg.get("content"):
|
| 231 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 232 |
+
|
| 233 |
+
messages.append({"role": "user", "content": prompt})
|
| 234 |
+
|
| 235 |
+
stream = client.chat.completions.create(
|
| 236 |
+
model=model,
|
| 237 |
+
messages=messages,
|
| 238 |
+
temperature=0.1,
|
| 239 |
+
max_tokens=1000,
|
| 240 |
+
stream=True,
|
| 241 |
+
)
|
| 242 |
+
for chunk in stream:
|
| 243 |
+
content = chunk.choices[0].delta.content
|
| 244 |
+
if content:
|
| 245 |
+
yield content
|
| 246 |
+
except Exception as e:
|
| 247 |
+
print(f"Groq Stream Error: {e}")
|
| 248 |
+
yield None
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# βββ Ollama Functions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 252 |
+
|
| 253 |
+
def get_ollama_response(prompt, history=None, model=DEFAULT_OLLAMA_MODEL, language="English"):
|
| 254 |
+
"""Fetches a response from a local Ollama instance."""
|
| 255 |
+
url = f"{OLLAMA_URL}/api/chat"
|
| 256 |
+
resolved_model = _resolve_ollama_model(model)
|
| 257 |
+
messages = _build_messages(prompt, history=history, language=language)
|
| 258 |
+
|
| 259 |
+
payload = {
|
| 260 |
+
"model": resolved_model,
|
| 261 |
+
"messages": messages,
|
| 262 |
+
"stream": False,
|
| 263 |
+
"options": {"temperature": 0.1, "top_p": 0.9, "num_predict": 500}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
# (connect_timeout, read_timeout) β cap total generation at 25s
|
| 268 |
+
response = requests.post(url, json=payload, timeout=(5, 25))
|
| 269 |
+
response.raise_for_status()
|
| 270 |
+
data = response.json()
|
| 271 |
+
return data.get("message", {}).get("content", "")
|
| 272 |
+
except requests.exceptions.Timeout:
|
| 273 |
+
# Don't retry on timeout β let the caller fall back to the next backend
|
| 274 |
+
print(f"Ollama timed out for model '{resolved_model}'. Falling back to next backend.")
|
| 275 |
+
return None
|
| 276 |
+
except Exception as e:
|
| 277 |
+
print(_ollama_error_message(resolved_model, e))
|
| 278 |
+
if resolved_model != "llama3":
|
| 279 |
+
return get_ollama_response(prompt, history, model="llama3", language=language)
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def stream_ollama_response(prompt, history=None, model=DEFAULT_OLLAMA_MODEL, language="English"):
|
| 284 |
+
"""Yields chunks of the response from a local Ollama instance for streaming."""
|
| 285 |
+
url = f"{OLLAMA_URL}/api/chat"
|
| 286 |
+
resolved_model = _resolve_ollama_model(model)
|
| 287 |
+
messages = _build_messages(prompt, history=history, language=language)
|
| 288 |
+
|
| 289 |
+
payload = {
|
| 290 |
+
"model": resolved_model,
|
| 291 |
+
"messages": messages,
|
| 292 |
+
"stream": True,
|
| 293 |
+
"options": {"temperature": 0.1, "top_p": 0.9, "num_predict": 500}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
# (connect_timeout, read_timeout) β cap total generation at 25s
|
| 298 |
+
response = requests.post(url, json=payload, timeout=(5, 25), stream=True)
|
| 299 |
+
response.raise_for_status()
|
| 300 |
+
|
| 301 |
+
for line in response.iter_lines():
|
| 302 |
+
if line:
|
| 303 |
+
chunk = json.loads(line)
|
| 304 |
+
if 'message' in chunk and 'content' in chunk['message']:
|
| 305 |
+
yield chunk['message']['content']
|
| 306 |
+
if chunk.get('done'):
|
| 307 |
+
break
|
| 308 |
+
except requests.exceptions.Timeout:
|
| 309 |
+
# Don't retry on timeout β let the caller fall back to the next backend
|
| 310 |
+
print(f"Ollama stream timed out for model '{resolved_model}'. Falling back to next backend.")
|
| 311 |
+
return
|
| 312 |
+
except Exception as e:
|
| 313 |
+
print(_ollama_error_message(resolved_model, e))
|
| 314 |
+
if resolved_model != "llama3":
|
| 315 |
+
yield from stream_ollama_response(prompt, history, model="llama3", language=language)
|
| 316 |
+
else:
|
| 317 |
+
yield None
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# βββ Unified Wrapper Functions ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
|
| 322 |
+
def get_ai_response(prompt, history=None, language="English"):
|
| 323 |
+
"""
|
| 324 |
+
Auto-selects the best available backend.
|
| 325 |
+
Priority: OpenAI β Groq β Ollama
|
| 326 |
+
Returns None only when all backends are unavailable.
|
| 327 |
+
"""
|
| 328 |
+
if OPENAI_API_KEY:
|
| 329 |
+
result = get_openai_response(prompt, history, language=language)
|
| 330 |
+
if result:
|
| 331 |
+
return result
|
| 332 |
+
|
| 333 |
+
if USE_GROQ:
|
| 334 |
+
result = get_groq_response(prompt, history, language=language)
|
| 335 |
+
if result:
|
| 336 |
+
return result
|
| 337 |
+
|
| 338 |
+
return get_ollama_response(prompt, history, language=language)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def stream_ai_response(prompt, history=None, language="English"):
|
| 342 |
+
"""
|
| 343 |
+
Auto-selects streaming from the best available backend.
|
| 344 |
+
Priority: OpenAI β Groq β Ollama
|
| 345 |
+
"""
|
| 346 |
+
if OPENAI_API_KEY:
|
| 347 |
+
chunks = list(stream_openai_response(prompt, history, language=language))
|
| 348 |
+
if chunks:
|
| 349 |
+
yield from chunks
|
| 350 |
+
return
|
| 351 |
+
|
| 352 |
+
if USE_GROQ:
|
| 353 |
+
chunks = list(stream_groq_response(prompt, history, language=language))
|
| 354 |
+
if chunks:
|
| 355 |
+
yield from chunks
|
| 356 |
+
return
|
| 357 |
+
|
| 358 |
+
yield from stream_ollama_response(prompt, history, language=language)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def check_ollama_connection():
|
| 362 |
+
"""Checks if the local Ollama server is running."""
|
| 363 |
+
if USE_GROQ:
|
| 364 |
+
return True
|
| 365 |
+
try:
|
| 366 |
+
response = requests.get(f"{OLLAMA_URL}/", timeout=2)
|
| 367 |
+
return response.status_code == 200
|
| 368 |
+
except:
|
| 369 |
+
return False
|
backend/app/ai/router.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
|
| 6 |
+
from app.database.database import get_db
|
| 7 |
+
from app.database.models import User
|
| 8 |
+
from app.middleware.cache import cache
|
| 9 |
+
|
| 10 |
+
# Import AI helper engines
|
| 11 |
+
from app.ai.forecasting import predict_future_balance, forecast_savings_and_investments, simulate_future_scenarios
|
| 12 |
+
from app.ai.simulation import simulate_purchase_impact, simulate_investment_impact, simulate_subscription_cancellation
|
| 13 |
+
from app.ai.behavior import analyze_spending_behavior
|
| 14 |
+
from app.ai.coaching import calculate_financial_health_score, generate_daily_briefing
|
| 15 |
+
from app.ai.subscriptions import analyze_subscriptions
|
| 16 |
+
from app.ai.fraud import evaluate_transaction_for_fraud, get_user_fraud_alerts
|
| 17 |
+
from app.ai.chat import get_chat_response
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/api/ai", tags=["AI Intelligence"])
|
| 20 |
+
|
| 21 |
+
# Fallback helper to retrieve a valid user ID for demonstration
|
| 22 |
+
def get_user_id_fallback(db: Session, user_id: Optional[str] = None) -> str:
|
| 23 |
+
if user_id:
|
| 24 |
+
return user_id
|
| 25 |
+
user = db.query(User).first()
|
| 26 |
+
if not user:
|
| 27 |
+
raise HTTPException(status_code=404, detail="No users found in database. Please seed the database first.")
|
| 28 |
+
return user.id
|
| 29 |
+
|
| 30 |
+
# Pydantic Schemas for input
|
| 31 |
+
class PurchaseRequest(BaseModel):
|
| 32 |
+
amount: float
|
| 33 |
+
merchant: str
|
| 34 |
+
category: str
|
| 35 |
+
|
| 36 |
+
class InvestmentRequest(BaseModel):
|
| 37 |
+
monthly_sip: float
|
| 38 |
+
asset_type: str
|
| 39 |
+
lump_sum: float = 0.0
|
| 40 |
+
|
| 41 |
+
class SubscriptionSimulationRequest(BaseModel):
|
| 42 |
+
subscription_ids: List[str]
|
| 43 |
+
|
| 44 |
+
class ChatMessageRequest(BaseModel):
|
| 45 |
+
message: str
|
| 46 |
+
|
| 47 |
+
# βββ FINANCIAL TWIN FORECASTS ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
|
| 49 |
+
@router.get("/twin/predict")
|
| 50 |
+
def get_twin_predict(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 51 |
+
uid = get_user_id_fallback(db, user_id)
|
| 52 |
+
cache_key = f"ai:twin:predict:{uid}"
|
| 53 |
+
|
| 54 |
+
cached = cache.get(cache_key)
|
| 55 |
+
if cached:
|
| 56 |
+
return cached
|
| 57 |
+
|
| 58 |
+
result = predict_future_balance(db, uid)
|
| 59 |
+
cache.set(cache_key, result, ttl=300) # cache for 5 minutes
|
| 60 |
+
return result
|
| 61 |
+
|
| 62 |
+
@router.get("/twin/future")
|
| 63 |
+
def get_twin_future(user_id: Optional[str] = None, months: int = Query(default=12, ge=1, le=60), db: Session = Depends(get_db)):
|
| 64 |
+
uid = get_user_id_fallback(db, user_id)
|
| 65 |
+
cache_key = f"ai:twin:future:{uid}:{months}"
|
| 66 |
+
|
| 67 |
+
cached = cache.get(cache_key)
|
| 68 |
+
if cached:
|
| 69 |
+
return cached
|
| 70 |
+
|
| 71 |
+
result = forecast_savings_and_investments(db, uid, months)
|
| 72 |
+
cache.set(cache_key, result, ttl=300)
|
| 73 |
+
return result
|
| 74 |
+
|
| 75 |
+
@router.get("/twin/scenarios")
|
| 76 |
+
def get_twin_scenarios(user_id: Optional[str] = None, months: int = Query(default=6, ge=1, le=24), db: Session = Depends(get_db)):
|
| 77 |
+
uid = get_user_id_fallback(db, user_id)
|
| 78 |
+
cache_key = f"ai:twin:scenarios:{uid}:{months}"
|
| 79 |
+
|
| 80 |
+
cached = cache.get(cache_key)
|
| 81 |
+
if cached:
|
| 82 |
+
return cached
|
| 83 |
+
|
| 84 |
+
result = simulate_future_scenarios(db, uid, months)
|
| 85 |
+
cache.set(cache_key, result, ttl=300)
|
| 86 |
+
return result
|
| 87 |
+
|
| 88 |
+
# βββ SIMULATION ENDPOINTS ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
@router.post("/simulate/purchase")
|
| 91 |
+
def post_simulate_purchase(req: PurchaseRequest, user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 92 |
+
uid = get_user_id_fallback(db, user_id)
|
| 93 |
+
return simulate_purchase_impact(db, uid, req.amount, req.category, req.merchant)
|
| 94 |
+
|
| 95 |
+
@router.post("/simulate/investment")
|
| 96 |
+
def post_simulate_investment(req: InvestmentRequest, user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 97 |
+
uid = get_user_id_fallback(db, user_id)
|
| 98 |
+
return simulate_investment_impact(db, uid, req.monthly_sip, req.asset_type, req.lump_sum)
|
| 99 |
+
|
| 100 |
+
@router.post("/simulate/subscription")
|
| 101 |
+
def post_simulate_subscription(req: SubscriptionSimulationRequest, user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 102 |
+
uid = get_user_id_fallback(db, user_id)
|
| 103 |
+
return simulate_subscription_cancellation(db, uid, req.subscription_ids)
|
| 104 |
+
|
| 105 |
+
# βββ BEHAVIORAL ANALYTICS βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
@router.get("/behavior/insights")
|
| 108 |
+
def get_behavior_insights(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 109 |
+
uid = get_user_id_fallback(db, user_id)
|
| 110 |
+
cache_key = f"ai:behavior:insights:{uid}"
|
| 111 |
+
|
| 112 |
+
cached = cache.get(cache_key)
|
| 113 |
+
if cached:
|
| 114 |
+
return cached
|
| 115 |
+
|
| 116 |
+
result = analyze_spending_behavior(db, uid)
|
| 117 |
+
cache.set(cache_key, result, ttl=600) # cache for 10 minutes
|
| 118 |
+
return result
|
| 119 |
+
|
| 120 |
+
# βββ COACHING & BRIEFINGS βββββββββββββββββββββββββββββββββοΏ½οΏ½βββββββββββββββββββ
|
| 121 |
+
|
| 122 |
+
@router.get("/coaching/briefing")
|
| 123 |
+
def get_coaching_briefing(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 124 |
+
uid = get_user_id_fallback(db, user_id)
|
| 125 |
+
# Cache briefings for 1 hour to prevent excessive LLM costs
|
| 126 |
+
cache_key = f"ai:coaching:briefing:{uid}"
|
| 127 |
+
|
| 128 |
+
cached = cache.get(cache_key)
|
| 129 |
+
if cached:
|
| 130 |
+
return cached
|
| 131 |
+
|
| 132 |
+
result = generate_daily_briefing(db, uid)
|
| 133 |
+
cache.set(cache_key, result, ttl=3600)
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
@router.get("/coaching/score")
|
| 137 |
+
def get_coaching_score(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 138 |
+
uid = get_user_id_fallback(db, user_id)
|
| 139 |
+
cache_key = f"ai:coaching:score:{uid}"
|
| 140 |
+
|
| 141 |
+
cached = cache.get(cache_key)
|
| 142 |
+
if cached:
|
| 143 |
+
return cached
|
| 144 |
+
|
| 145 |
+
result = calculate_financial_health_score(db, uid)
|
| 146 |
+
cache.set(cache_key, result, ttl=600)
|
| 147 |
+
return result
|
| 148 |
+
|
| 149 |
+
# βββ SUBSCRIPTION OPTIMIZATION ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
|
| 151 |
+
@router.get("/subscriptions/optimize")
|
| 152 |
+
def get_subscriptions_optimize(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 153 |
+
uid = get_user_id_fallback(db, user_id)
|
| 154 |
+
cache_key = f"ai:subs:optimize:{uid}"
|
| 155 |
+
|
| 156 |
+
cached = cache.get(cache_key)
|
| 157 |
+
if cached:
|
| 158 |
+
return cached
|
| 159 |
+
|
| 160 |
+
result = analyze_subscriptions(db, uid)
|
| 161 |
+
cache.set(cache_key, result, ttl=600)
|
| 162 |
+
return result
|
| 163 |
+
|
| 164 |
+
# βββ FRAUD & SECURITY βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 165 |
+
|
| 166 |
+
@router.get("/fraud/analysis")
|
| 167 |
+
def get_fraud_analysis(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 168 |
+
uid = get_user_id_fallback(db, user_id)
|
| 169 |
+
return get_user_fraud_alerts(db, uid)
|
| 170 |
+
|
| 171 |
+
@router.post("/fraud/evaluate/{transaction_id}")
|
| 172 |
+
def post_fraud_evaluate(transaction_id: str, db: Session = Depends(get_db)):
|
| 173 |
+
return evaluate_transaction_for_fraud(db, transaction_id)
|
| 174 |
+
|
| 175 |
+
# βββ CONTEXTUAL CHAT ENDPOINT ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
+
@router.post("/chat")
|
| 178 |
+
def post_chat(req: ChatMessageRequest, user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 179 |
+
uid = get_user_id_fallback(db, user_id)
|
| 180 |
+
response_msg = get_chat_response(db, uid, req.message)
|
| 181 |
+
return {"response": response_msg}
|
backend/app/ai/simulation.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
from app.database.models import Account, Goal, Investment, Subscription
|
| 3 |
+
from app.ai.forecasting import get_cashflow_metrics
|
| 4 |
+
|
| 5 |
+
def simulate_purchase_impact(db: Session, user_id: str, amount: float, category: str, merchant: str):
|
| 6 |
+
"""
|
| 7 |
+
Simulates buying a large asset or item (e.g. a car) and assesses risk.
|
| 8 |
+
"""
|
| 9 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 10 |
+
total_balance = sum(acc.balance for acc in accounts)
|
| 11 |
+
checking_acc = next((a for a in accounts if a.type.lower() == "checking"), None)
|
| 12 |
+
|
| 13 |
+
# Target emergency fund amount
|
| 14 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 15 |
+
emergency_goal = next((g for g in goals if "emergency" in g.title.lower()), None)
|
| 16 |
+
emergency_threshold = emergency_goal.target_amount if emergency_goal else 3000.0
|
| 17 |
+
|
| 18 |
+
new_balance = total_balance - amount
|
| 19 |
+
|
| 20 |
+
# Cashflow metrics
|
| 21 |
+
_, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 22 |
+
monthly_net = (daily_income - daily_spending) * 30.4
|
| 23 |
+
|
| 24 |
+
# Risk Analysis
|
| 25 |
+
risk_level = "low"
|
| 26 |
+
reasons = []
|
| 27 |
+
|
| 28 |
+
if amount > total_balance:
|
| 29 |
+
risk_level = "critical"
|
| 30 |
+
reasons.append("Purchase exceeds your total available balance, requiring debt.")
|
| 31 |
+
elif new_balance < emergency_threshold:
|
| 32 |
+
risk_level = "high"
|
| 33 |
+
reasons.append(f"This purchase depletes your emergency buffer (threshold of ${emergency_threshold:,.2f}).")
|
| 34 |
+
elif amount > total_balance * 0.3:
|
| 35 |
+
risk_level = "medium"
|
| 36 |
+
reasons.append("Single purchase consumes more than 30% of your total liquid cash.")
|
| 37 |
+
|
| 38 |
+
if monthly_net < 0 and amount > 500:
|
| 39 |
+
risk_level = "high"
|
| 40 |
+
reasons.append("You have a negative monthly cashflow; making large purchases increases financial strain.")
|
| 41 |
+
|
| 42 |
+
# Recommendations
|
| 43 |
+
recommendation = ""
|
| 44 |
+
if risk_level == "critical":
|
| 45 |
+
recommendation = "β Strongly advise against this purchase. Consider financing options, delaying, or establishing a dedicated goal."
|
| 46 |
+
elif risk_level == "high":
|
| 47 |
+
recommendation = "β οΈ Refrain from this purchase if possible. Rebuilding your emergency fund should be prioritized."
|
| 48 |
+
elif risk_level == "medium":
|
| 49 |
+
recommendation = "π‘ Proceed with caution. Consider trimming discretionary expenses next month to offset the cost."
|
| 50 |
+
else:
|
| 51 |
+
recommendation = "β
Purchase is safe. It fits within your financial profile without impacting key safety buffers."
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"purchase_amount": amount,
|
| 55 |
+
"merchant": merchant,
|
| 56 |
+
"category": category,
|
| 57 |
+
"current_balance": round(total_balance, 2),
|
| 58 |
+
"projected_balance": round(max(0.0, new_balance), 2),
|
| 59 |
+
"savings_impact": {
|
| 60 |
+
"immediate_reduction": round(amount, 2),
|
| 61 |
+
"emergency_buffer_breached": new_balance < emergency_threshold,
|
| 62 |
+
"emergency_threshold": round(emergency_threshold, 2)
|
| 63 |
+
},
|
| 64 |
+
"risk_analysis": {
|
| 65 |
+
"risk_level": risk_level,
|
| 66 |
+
"reasons": reasons
|
| 67 |
+
},
|
| 68 |
+
"recommendation": recommendation
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
def simulate_investment_impact(db: Session, user_id: str, monthly_sip: float, asset_type: str, lump_sum: float = 0.0):
|
| 72 |
+
"""
|
| 73 |
+
Simulates investment growth and evaluates opportunity cost.
|
| 74 |
+
"""
|
| 75 |
+
# Expected annual returns based on asset type
|
| 76 |
+
returns_map = {
|
| 77 |
+
"stock": 0.10, # 10%
|
| 78 |
+
"crypto": 0.20, # 20%
|
| 79 |
+
"mutual_fund": 0.08, # 8%
|
| 80 |
+
"fd": 0.05, # 5%
|
| 81 |
+
"bond": 0.04 # 4%
|
| 82 |
+
}
|
| 83 |
+
apr = returns_map.get(asset_type.lower(), 0.07)
|
| 84 |
+
|
| 85 |
+
# Calculate current balance
|
| 86 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 87 |
+
total_balance = sum(acc.balance for acc in accounts)
|
| 88 |
+
|
| 89 |
+
# Cashflow metrics
|
| 90 |
+
_, daily_income, daily_spending = get_cashflow_metrics(db, user_id)
|
| 91 |
+
monthly_net = (daily_income - daily_spending) * 30.4
|
| 92 |
+
|
| 93 |
+
# Check if SIP is affordable
|
| 94 |
+
is_affordable = monthly_net >= monthly_sip
|
| 95 |
+
|
| 96 |
+
growth_projection = []
|
| 97 |
+
current_value = lump_sum
|
| 98 |
+
total_invested = lump_sum
|
| 99 |
+
|
| 100 |
+
# 5-year monthly projection
|
| 101 |
+
for month in range(0, 61):
|
| 102 |
+
if month > 0:
|
| 103 |
+
current_value = (current_value + monthly_sip) * (1 + apr / 12)
|
| 104 |
+
total_invested += monthly_sip
|
| 105 |
+
|
| 106 |
+
if month in [12, 36, 60]: # Save 1, 3, 5 year markers
|
| 107 |
+
growth_projection.append({
|
| 108 |
+
"year": month // 12,
|
| 109 |
+
"total_invested": round(total_invested, 2),
|
| 110 |
+
"future_value": round(current_value, 2),
|
| 111 |
+
"earnings": round(max(0.0, current_value - total_invested), 2)
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
risk_level = "low"
|
| 115 |
+
if asset_type.lower() == "crypto":
|
| 116 |
+
risk_level = "high"
|
| 117 |
+
elif asset_type.lower() in ["stock", "mutual_fund"] and monthly_sip > monthly_net * 0.5:
|
| 118 |
+
risk_level = "medium"
|
| 119 |
+
|
| 120 |
+
recommendation = ""
|
| 121 |
+
if not is_affordable:
|
| 122 |
+
recommendation = f"β οΈ Your monthly net surplus (${monthly_net:,.2f}) is lower than the planned SIP (${monthly_sip:,.2f}). This may lead to checking overdrafts."
|
| 123 |
+
else:
|
| 124 |
+
recommendation = f"β
Excellent choice. Investing ${monthly_sip:,.2f} monthly in {asset_type} is fully supported by your net cashflow."
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"asset_type": asset_type,
|
| 128 |
+
"monthly_sip": round(monthly_sip, 2),
|
| 129 |
+
"lump_sum": round(lump_sum, 2),
|
| 130 |
+
"is_affordable": is_affordable,
|
| 131 |
+
"growth_projection": growth_projection,
|
| 132 |
+
"risk_analysis": {
|
| 133 |
+
"risk_level": risk_level,
|
| 134 |
+
"expected_annual_return": apr
|
| 135 |
+
},
|
| 136 |
+
"savings_impact": {
|
| 137 |
+
"opportunity_cost_yearly": round(monthly_sip * 12, 2),
|
| 138 |
+
"monthly_surplus_retaining": round(max(0.0, monthly_net - monthly_sip), 2)
|
| 139 |
+
},
|
| 140 |
+
"recommendation": recommendation
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
def simulate_subscription_cancellation(db: Session, user_id: str, subscription_ids: list):
|
| 144 |
+
"""
|
| 145 |
+
Simulates the financial benefit of cancelling one or more subscriptions.
|
| 146 |
+
"""
|
| 147 |
+
subs = db.query(Subscription).filter(
|
| 148 |
+
Subscription.user_id == user_id,
|
| 149 |
+
Subscription.id.in_(subscription_ids)
|
| 150 |
+
).all()
|
| 151 |
+
|
| 152 |
+
if not subs:
|
| 153 |
+
return {"message": "No matching subscriptions found for cancellation simulation."}
|
| 154 |
+
|
| 155 |
+
monthly_savings = 0.0
|
| 156 |
+
yearly_savings = 0.0
|
| 157 |
+
cancelled_details = []
|
| 158 |
+
|
| 159 |
+
for sub in subs:
|
| 160 |
+
cost = sub.amount
|
| 161 |
+
is_monthly = sub.billing_cycle.lower() == "monthly"
|
| 162 |
+
|
| 163 |
+
m_cost = cost if is_monthly else (cost / 12)
|
| 164 |
+
y_cost = (cost * 12) if is_monthly else cost
|
| 165 |
+
|
| 166 |
+
monthly_savings += m_cost
|
| 167 |
+
yearly_savings += y_cost
|
| 168 |
+
|
| 169 |
+
cancelled_details.append({
|
| 170 |
+
"id": sub.id,
|
| 171 |
+
"merchant": sub.merchant,
|
| 172 |
+
"amount": sub.amount,
|
| 173 |
+
"billing_cycle": sub.billing_cycle
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
# Relate savings to user's Goals
|
| 177 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 178 |
+
first_goal = goals[0] if goals else None
|
| 179 |
+
|
| 180 |
+
goal_impact = None
|
| 181 |
+
if first_goal:
|
| 182 |
+
months_saved = 0.0
|
| 183 |
+
remaining_needed = max(0.0, first_goal.target_amount - first_goal.current_amount)
|
| 184 |
+
if monthly_savings > 0:
|
| 185 |
+
months_saved = remaining_needed / (remaining_needed / 12 if remaining_needed > 0 else 1) # simple logic
|
| 186 |
+
# Let's say if they direct this money to goal, it reduces target time by:
|
| 187 |
+
months_saved = (remaining_needed / monthly_savings) if remaining_needed > 0 else 0
|
| 188 |
+
|
| 189 |
+
goal_impact = {
|
| 190 |
+
"goal_title": first_goal.title,
|
| 191 |
+
"target_amount": round(first_goal.target_amount, 2),
|
| 192 |
+
"months_to_reach_with_savings": round(months_saved, 1) if monthly_savings > 0 else 0
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
# Recommendations
|
| 196 |
+
recommendation = f"Cancelling these subscriptions yields ${monthly_savings:,.2f} per month (${yearly_savings:,.2f} annually). Reinvesting these funds into high-yield savings or mutual funds is recommended."
|
| 197 |
+
|
| 198 |
+
return {
|
| 199 |
+
"cancelled_subscriptions": cancelled_details,
|
| 200 |
+
"monthly_savings": round(monthly_savings, 2),
|
| 201 |
+
"yearly_savings": round(yearly_savings, 2),
|
| 202 |
+
"goal_impact": goal_impact,
|
| 203 |
+
"recommendation": recommendation
|
| 204 |
+
}
|
backend/app/ai/subscriptions.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import defaultdict
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.database.models import Subscription
|
| 4 |
+
|
| 5 |
+
def analyze_subscriptions(db: Session, user_id: str):
|
| 6 |
+
"""
|
| 7 |
+
Analyzes subscriptions to detect duplicates, unused memberships, and cancellation opportunities.
|
| 8 |
+
"""
|
| 9 |
+
subs = db.query(Subscription).filter(
|
| 10 |
+
Subscription.user_id == user_id,
|
| 11 |
+
Subscription.active == True
|
| 12 |
+
).all()
|
| 13 |
+
|
| 14 |
+
if not subs:
|
| 15 |
+
return {
|
| 16 |
+
"subscriptions": [],
|
| 17 |
+
"duplicates": [],
|
| 18 |
+
"unused_subscriptions": [],
|
| 19 |
+
"yearly_savings_potential": 0.0,
|
| 20 |
+
"risk_analysis": []
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
merchant_map = defaultdict(list)
|
| 24 |
+
unused_list = []
|
| 25 |
+
cancellation_suggestions = []
|
| 26 |
+
yearly_savings = 0.0
|
| 27 |
+
risk_analysis = []
|
| 28 |
+
|
| 29 |
+
for s in subs:
|
| 30 |
+
# Standardize merchant name to detect duplicates
|
| 31 |
+
clean_merchant = s.merchant.strip().lower()
|
| 32 |
+
merchant_map[clean_merchant].append(s)
|
| 33 |
+
|
| 34 |
+
# Determine cost
|
| 35 |
+
m_cost = s.amount if s.billing_cycle.lower() == "monthly" else (s.amount / 12)
|
| 36 |
+
y_cost = (s.amount * 12) if s.billing_cycle.lower() == "monthly" else s.amount
|
| 37 |
+
|
| 38 |
+
# Detect unused (if frequency is 'low' or 'none' in usage detection metadata)
|
| 39 |
+
usage = s.ai_usage_detection or {}
|
| 40 |
+
freq = str(usage.get("usage_frequency", "medium")).lower()
|
| 41 |
+
|
| 42 |
+
if freq in ["low", "none", "unused"]:
|
| 43 |
+
unused_list.append(s)
|
| 44 |
+
cancellation_suggestions.append({
|
| 45 |
+
"subscription_id": s.id,
|
| 46 |
+
"merchant": s.merchant,
|
| 47 |
+
"amount": s.amount,
|
| 48 |
+
"billing_cycle": s.billing_cycle,
|
| 49 |
+
"reason": f"Usage frequency is flagged as '{freq}'.",
|
| 50 |
+
"yearly_savings": round(y_cost, 2)
|
| 51 |
+
})
|
| 52 |
+
yearly_savings += y_cost
|
| 53 |
+
|
| 54 |
+
# Detect duplicates
|
| 55 |
+
duplicates = []
|
| 56 |
+
for merchant, items in merchant_map.items():
|
| 57 |
+
if len(items) > 1:
|
| 58 |
+
total_cost = sum(x.amount for x in items)
|
| 59 |
+
duplicates.append({
|
| 60 |
+
"merchant": items[0].merchant,
|
| 61 |
+
"count": len(items),
|
| 62 |
+
"items": [
|
| 63 |
+
{
|
| 64 |
+
"id": x.id,
|
| 65 |
+
"amount": x.amount,
|
| 66 |
+
"billing_cycle": x.billing_cycle
|
| 67 |
+
}
|
| 68 |
+
for x in items
|
| 69 |
+
],
|
| 70 |
+
"recommendation": f"You have {len(items)} active subscriptions for {items[0].merchant}. Consolidate to a single account to save."
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
# Risk Analysis (utilities vs entertainment)
|
| 74 |
+
essential_categories = ["electricity", "water", "gas", "internet", "phone", "insurance"]
|
| 75 |
+
for s in subs:
|
| 76 |
+
is_essential = any(k in s.merchant.lower() for k in essential_categories)
|
| 77 |
+
if is_essential:
|
| 78 |
+
risk_analysis.append({
|
| 79 |
+
"merchant": s.merchant,
|
| 80 |
+
"risk_level": "high",
|
| 81 |
+
"consequences": "Utility interruption, account reactivation fees, or legal service contract breaches."
|
| 82 |
+
})
|
| 83 |
+
else:
|
| 84 |
+
risk_analysis.append({
|
| 85 |
+
"merchant": s.merchant,
|
| 86 |
+
"risk_level": "low",
|
| 87 |
+
"consequences": "Loss of entertainment streaming access only. Service can be reactivated instantly."
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"subscriptions": [
|
| 92 |
+
{
|
| 93 |
+
"id": s.id,
|
| 94 |
+
"merchant": s.merchant,
|
| 95 |
+
"amount": s.amount,
|
| 96 |
+
"billing_cycle": s.billing_cycle,
|
| 97 |
+
"usage_frequency": s.ai_usage_detection.get("usage_frequency", "medium") if s.ai_usage_detection else "medium"
|
| 98 |
+
}
|
| 99 |
+
for s in subs
|
| 100 |
+
],
|
| 101 |
+
"duplicates": duplicates,
|
| 102 |
+
"unused_subscriptions": cancellation_suggestions,
|
| 103 |
+
"yearly_savings_potential": round(yearly_savings, 2),
|
| 104 |
+
"risk_analysis": risk_analysis
|
| 105 |
+
}
|
backend/app/ai/voice_assistant.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Voice Banking Assistant for BankBot
|
| 3 |
+
Enables voice-based banking queries and responses
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import speech_recognition as sr
|
| 9 |
+
import pyttsx3
|
| 10 |
+
from gtts import gTTS
|
| 11 |
+
import io
|
| 12 |
+
import streamlit as st
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
class VoiceAssistant:
|
| 16 |
+
"""Handles voice input and output for banking queries"""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.recognizer = sr.Recognizer()
|
| 20 |
+
self.engine = pyttsx3.init()
|
| 21 |
+
self.engine.setProperty('rate', 150) # Speaking rate
|
| 22 |
+
self.microphone = sr.Microphone()
|
| 23 |
+
|
| 24 |
+
# Initialize text-to-speech properties
|
| 25 |
+
self.engine.setProperty('volume', 0.9) # Volume level (0.0 to 1.0)
|
| 26 |
+
|
| 27 |
+
def listen_to_user(self, timeout=10):
|
| 28 |
+
"""
|
| 29 |
+
Capture audio input from microphone and convert to text
|
| 30 |
+
Returns: Recognized text or None if recognition fails
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
with self.microphone as source:
|
| 34 |
+
# Adjust for ambient noise
|
| 35 |
+
self.recognizer.adjust_for_ambient_noise(source, duration=0.5)
|
| 36 |
+
|
| 37 |
+
# Listen for audio
|
| 38 |
+
audio = self.recognizer.listen(source, timeout=timeout, phrase_time_limit=15)
|
| 39 |
+
|
| 40 |
+
# Try to recognize using Google Speech Recognition
|
| 41 |
+
text = self.recognizer.recognize_google(audio)
|
| 42 |
+
return text.lower()
|
| 43 |
+
|
| 44 |
+
except sr.RequestError as e:
|
| 45 |
+
return None # Could not request results; network error
|
| 46 |
+
except sr.UnknownValueError:
|
| 47 |
+
return None # Unable to recognize speech
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"Error listening to user: {e}")
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
def speak_response(self, text, use_gtts=False):
|
| 53 |
+
"""
|
| 54 |
+
Provide audio output for the response
|
| 55 |
+
Args:
|
| 56 |
+
text: Response text
|
| 57 |
+
use_gtts: Use Google Text-to-Speech instead of pyttsx3
|
| 58 |
+
Returns: Audio data or None
|
| 59 |
+
"""
|
| 60 |
+
try:
|
| 61 |
+
if use_gtts:
|
| 62 |
+
# Use Google TTS (requires internet, better quality)
|
| 63 |
+
tts = gTTS(text=text, lang='en', slow=False)
|
| 64 |
+
audio_fp = io.BytesIO()
|
| 65 |
+
tts.write_to_fp(audio_fp)
|
| 66 |
+
audio_fp.seek(0)
|
| 67 |
+
return audio_fp
|
| 68 |
+
else:
|
| 69 |
+
# Use pyttsx3 (offline, works locally)
|
| 70 |
+
self.engine.say(text)
|
| 71 |
+
self.engine.runAndWait()
|
| 72 |
+
return True
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"Error in text-to-speech: {e}")
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
def process_voice_query(self, transcribed_text, user_data, transactions):
|
| 79 |
+
"""
|
| 80 |
+
Process voice query and extract banking intent
|
| 81 |
+
Returns: Query type, extracted information
|
| 82 |
+
"""
|
| 83 |
+
text_lower = transcribed_text.lower()
|
| 84 |
+
|
| 85 |
+
# Balance query
|
| 86 |
+
if any(word in text_lower for word in ["balance", "how much", "account balance"]):
|
| 87 |
+
return "balance", None
|
| 88 |
+
|
| 89 |
+
# Transaction history
|
| 90 |
+
elif any(word in text_lower for word in ["transactions", "history", "recent", "last"]):
|
| 91 |
+
return "transactions", None
|
| 92 |
+
|
| 93 |
+
# Spending analysis
|
| 94 |
+
elif any(word in text_lower for word in ["spending", "spent", "expenses", "how much did i spend"]):
|
| 95 |
+
return "spending", None
|
| 96 |
+
|
| 97 |
+
# Transfer query
|
| 98 |
+
elif any(word in text_lower for word in ["transfer", "send", "pay"]):
|
| 99 |
+
return "transfer", None
|
| 100 |
+
|
| 101 |
+
# Loan info
|
| 102 |
+
elif any(word in text_lower for word in ["loan", "emi", "borrow", "credit"]):
|
| 103 |
+
return "loan", None
|
| 104 |
+
|
| 105 |
+
# FD/Investment
|
| 106 |
+
elif any(word in text_lower for word in ["fixed deposit", "fd", "invest", "investment"]):
|
| 107 |
+
return "fd", None
|
| 108 |
+
|
| 109 |
+
# Help/Support
|
| 110 |
+
elif any(word in text_lower for word in ["help", "support", "assist", "how do i"]):
|
| 111 |
+
return "help", None
|
| 112 |
+
|
| 113 |
+
else:
|
| 114 |
+
return "general", None
|
| 115 |
+
|
| 116 |
+
def generate_voice_response(self, query_type, user_data, transactions, get_ai_response_fn=None):
|
| 117 |
+
"""
|
| 118 |
+
Generate appropriate response for voice query
|
| 119 |
+
Returns: Response text and audio
|
| 120 |
+
"""
|
| 121 |
+
balance = user_data.get('balance', 0)
|
| 122 |
+
|
| 123 |
+
if query_type == "balance":
|
| 124 |
+
response = f"Your current account balance is rupees {balance:.2f}"
|
| 125 |
+
|
| 126 |
+
elif query_type == "transactions":
|
| 127 |
+
recent = transactions[:5] if transactions else []
|
| 128 |
+
if not recent:
|
| 129 |
+
response = "You have no recent transactions."
|
| 130 |
+
else:
|
| 131 |
+
response = f"Your last transaction was {recent[0].get('amount')} rupees for {recent[0].get('details', 'banking service')}"
|
| 132 |
+
|
| 133 |
+
elif query_type == "spending":
|
| 134 |
+
# Calculate spending
|
| 135 |
+
debit_txns = [t for t in transactions if t.get('type') == 'debit']
|
| 136 |
+
total_spent = sum(float(t.get('amount', 0)) for t in debit_txns[-10:])
|
| 137 |
+
response = f"You have spent {total_spent:.2f} rupees in your recent transactions."
|
| 138 |
+
|
| 139 |
+
elif query_type == "help":
|
| 140 |
+
response = "I can help you with balance inquiries, transaction history, spending analysis, fund transfers, and loan information. What would you like to know?"
|
| 141 |
+
|
| 142 |
+
elif query_type == "general" and get_ai_response_fn:
|
| 143 |
+
# Use AI for general banking queries
|
| 144 |
+
response = get_ai_response_fn("user query", [])
|
| 145 |
+
|
| 146 |
+
else:
|
| 147 |
+
response = "I didn't quite understand. Could you please rephrase your question?"
|
| 148 |
+
|
| 149 |
+
return response
|
| 150 |
+
|
| 151 |
+
def extract_voice_command(self, transcribed_text):
|
| 152 |
+
"""Extract command-specific parameters from voice input"""
|
| 153 |
+
# Extract amounts from voice
|
| 154 |
+
amount_words = {
|
| 155 |
+
"hundred": 100, "thousand": 1000, "lakh": 100000,
|
| 156 |
+
"rupees": 1, "rupee": 1, "paisa": 0.01
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
# Extract recipient name if present
|
| 160 |
+
# Extract date references if present
|
| 161 |
+
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
def record_voice_query(username, users_data, get_ai_response_fn):
|
| 165 |
+
"""
|
| 166 |
+
Record and process voice query through Streamlit UI
|
| 167 |
+
"""
|
| 168 |
+
st.markdown("""
|
| 169 |
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 170 |
+
padding: 20px; border-radius: 10px; margin: 10px 0;">
|
| 171 |
+
<h3 style="color: white; margin: 0;">π€ Voice Banking Assistant</h3>
|
| 172 |
+
</div>
|
| 173 |
+
""", unsafe_allow_html=True)
|
| 174 |
+
|
| 175 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 176 |
+
|
| 177 |
+
with col2:
|
| 178 |
+
if st.button("ποΈ Start Recording", key="voice_record", use_container_width=True):
|
| 179 |
+
with st.spinner("π§ Listening... Speak now!"):
|
| 180 |
+
assistant = VoiceAssistant()
|
| 181 |
+
recognized_text = assistant.listen_to_user(timeout=10)
|
| 182 |
+
|
| 183 |
+
if recognized_text:
|
| 184 |
+
st.success(f"β
Recognized: {recognized_text}")
|
| 185 |
+
|
| 186 |
+
# Process the query
|
| 187 |
+
user_data = users_data.get(username, {})
|
| 188 |
+
transactions = user_data.get('transactions', [])
|
| 189 |
+
|
| 190 |
+
query_type, _ = assistant.process_voice_query(recognized_text, user_data, transactions)
|
| 191 |
+
response = assistant.generate_voice_response(
|
| 192 |
+
query_type,
|
| 193 |
+
user_data,
|
| 194 |
+
transactions,
|
| 195 |
+
get_ai_response_fn
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Display response
|
| 199 |
+
st.info(f"π€ Response: {response}")
|
| 200 |
+
|
| 201 |
+
# Provide audio feedback
|
| 202 |
+
with st.spinner("π Converting to speech..."):
|
| 203 |
+
assistant.speak_response(response, use_gtts=False)
|
| 204 |
+
|
| 205 |
+
st.success("β
Response delivered")
|
| 206 |
+
else:
|
| 207 |
+
st.error("β Could not recognize speech. Please try again.")
|
| 208 |
+
|
| 209 |
+
def voice_mode_demo():
|
| 210 |
+
"""Demo voice banking queries"""
|
| 211 |
+
demo_queries = [
|
| 212 |
+
"What's my balance?",
|
| 213 |
+
"Show my recent transactions",
|
| 214 |
+
"How much did I spend this month?",
|
| 215 |
+
"Transfer 5000 to John",
|
| 216 |
+
"Tell me about loan eligibility"
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
return demo_queries
|
backend/app/auth/__init__.py
ADDED
|
File without changes
|
backend/app/auth/router.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication router β JWT login, register, refresh, logout.
|
| 3 |
+
Uses bcrypt directly (avoids passlib 1.7.4 + bcrypt>=4 incompatibility).
|
| 4 |
+
Uses python-jose for JWT.
|
| 5 |
+
"""
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import bcrypt as _bcrypt
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 11 |
+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
| 12 |
+
from jose import JWTError, jwt
|
| 13 |
+
from pydantic import BaseModel, EmailStr
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
|
| 16 |
+
from app.database.database import get_db
|
| 17 |
+
from app.database.models import User, generate_uuid
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
| 21 |
+
|
| 22 |
+
# βββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "bankbot-dev-secret-change-in-production")
|
| 24 |
+
ALGORITHM = os.environ.get("JWT_ALGORITHM", "HS256")
|
| 25 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
| 26 |
+
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
| 27 |
+
|
| 28 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
| 29 |
+
|
| 30 |
+
# βββ Schemas ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
class RegisterRequest(BaseModel):
|
| 32 |
+
email: EmailStr
|
| 33 |
+
password: str
|
| 34 |
+
name: str
|
| 35 |
+
|
| 36 |
+
class LoginResponse(BaseModel):
|
| 37 |
+
access_token: str
|
| 38 |
+
refresh_token: str
|
| 39 |
+
token_type: str = "bearer"
|
| 40 |
+
user_id: str
|
| 41 |
+
name: str
|
| 42 |
+
email: str
|
| 43 |
+
|
| 44 |
+
class RefreshRequest(BaseModel):
|
| 45 |
+
refresh_token: str
|
| 46 |
+
|
| 47 |
+
class TokenData(BaseModel):
|
| 48 |
+
user_id: Optional[str] = None
|
| 49 |
+
token_type: Optional[str] = None
|
| 50 |
+
|
| 51 |
+
# βββ Password helpers (bcrypt direct β no passlib) ββββββββββββββββββββββββββββ
|
| 52 |
+
def hash_password(password: str) -> str:
|
| 53 |
+
return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt(rounds=12)).decode("utf-8")
|
| 54 |
+
|
| 55 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 56 |
+
try:
|
| 57 |
+
return _bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
| 58 |
+
except Exception:
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
# βββ JWT helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
def create_token(data: dict, expires_delta: timedelta) -> str:
|
| 63 |
+
payload = data.copy()
|
| 64 |
+
payload["exp"] = datetime.utcnow() + expires_delta
|
| 65 |
+
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
| 66 |
+
|
| 67 |
+
def create_access_token(user_id: str) -> str:
|
| 68 |
+
return create_token(
|
| 69 |
+
{"sub": user_id, "type": "access"},
|
| 70 |
+
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def create_refresh_token(user_id: str) -> str:
|
| 74 |
+
return create_token(
|
| 75 |
+
{"sub": user_id, "type": "refresh"},
|
| 76 |
+
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
def decode_token(token: str) -> TokenData:
|
| 80 |
+
try:
|
| 81 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 82 |
+
user_id: str = payload.get("sub")
|
| 83 |
+
token_type: str = payload.get("type")
|
| 84 |
+
if not user_id:
|
| 85 |
+
raise HTTPException(status_code=401, detail="Invalid token payload")
|
| 86 |
+
return TokenData(user_id=user_id, token_type=token_type)
|
| 87 |
+
except JWTError:
|
| 88 |
+
raise HTTPException(
|
| 89 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 90 |
+
detail="Token expired or invalid",
|
| 91 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# βββ Auth dependencies ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
def get_current_user(
|
| 96 |
+
token: Optional[str] = Depends(oauth2_scheme),
|
| 97 |
+
db: Session = Depends(get_db),
|
| 98 |
+
) -> User:
|
| 99 |
+
if not token:
|
| 100 |
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 101 |
+
token_data = decode_token(token)
|
| 102 |
+
if token_data.token_type != "access":
|
| 103 |
+
raise HTTPException(status_code=401, detail="Invalid token type")
|
| 104 |
+
user = db.query(User).filter(User.id == token_data.user_id).first()
|
| 105 |
+
if not user:
|
| 106 |
+
raise HTTPException(status_code=401, detail="User not found")
|
| 107 |
+
return user
|
| 108 |
+
|
| 109 |
+
def get_current_user_optional(
|
| 110 |
+
token: Optional[str] = Depends(oauth2_scheme),
|
| 111 |
+
db: Session = Depends(get_db),
|
| 112 |
+
) -> Optional[User]:
|
| 113 |
+
if not token:
|
| 114 |
+
return None
|
| 115 |
+
try:
|
| 116 |
+
return get_current_user(token, db)
|
| 117 |
+
except HTTPException:
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
# βββ Routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
+
@router.post("/register", response_model=LoginResponse, status_code=201)
|
| 122 |
+
def register(req: RegisterRequest, db: Session = Depends(get_db)):
|
| 123 |
+
existing = db.query(User).filter(User.email == req.email).first()
|
| 124 |
+
if existing:
|
| 125 |
+
raise HTTPException(status_code=409, detail="Email already registered")
|
| 126 |
+
|
| 127 |
+
user = User(
|
| 128 |
+
id=generate_uuid(),
|
| 129 |
+
email=req.email,
|
| 130 |
+
password_hash=hash_password(req.password),
|
| 131 |
+
profile_data={"name": req.name},
|
| 132 |
+
financial_personality="Balanced",
|
| 133 |
+
)
|
| 134 |
+
db.add(user)
|
| 135 |
+
db.commit()
|
| 136 |
+
db.refresh(user)
|
| 137 |
+
|
| 138 |
+
return LoginResponse(
|
| 139 |
+
access_token=create_access_token(user.id),
|
| 140 |
+
refresh_token=create_refresh_token(user.id),
|
| 141 |
+
user_id=user.id,
|
| 142 |
+
name=req.name,
|
| 143 |
+
email=user.email,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
@router.post("/login", response_model=LoginResponse)
|
| 147 |
+
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
| 148 |
+
user = db.query(User).filter(User.email == form.username).first()
|
| 149 |
+
if not user or not verify_password(form.password, user.password_hash):
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 152 |
+
detail="Incorrect email or password",
|
| 153 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 154 |
+
)
|
| 155 |
+
return LoginResponse(
|
| 156 |
+
access_token=create_access_token(user.id),
|
| 157 |
+
refresh_token=create_refresh_token(user.id),
|
| 158 |
+
user_id=user.id,
|
| 159 |
+
name=user.profile_data.get("name", "User"),
|
| 160 |
+
email=user.email,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
@router.post("/refresh")
|
| 164 |
+
def refresh_token(req: RefreshRequest, db: Session = Depends(get_db)):
|
| 165 |
+
token_data = decode_token(req.refresh_token)
|
| 166 |
+
if token_data.token_type != "refresh":
|
| 167 |
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 168 |
+
user = db.query(User).filter(User.id == token_data.user_id).first()
|
| 169 |
+
if not user:
|
| 170 |
+
raise HTTPException(status_code=401, detail="User not found")
|
| 171 |
+
return {
|
| 172 |
+
"access_token": create_access_token(user.id),
|
| 173 |
+
"token_type": "bearer",
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
@router.get("/me")
|
| 177 |
+
def get_me(current_user: User = Depends(get_current_user)):
|
| 178 |
+
return {
|
| 179 |
+
"user_id": current_user.id,
|
| 180 |
+
"email": current_user.email,
|
| 181 |
+
"name": current_user.profile_data.get("name", "User"),
|
| 182 |
+
"financial_personality": current_user.financial_personality,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
@router.post("/logout")
|
| 186 |
+
def logout():
|
| 187 |
+
# Stateless JWT β client drops the token.
|
| 188 |
+
# Production: add token to a Redis blacklist here.
|
| 189 |
+
return {"message": "Logged out successfully"}
|
backend/app/dashboard/__init__.py
ADDED
|
File without changes
|
backend/app/dashboard/router.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dashboard router β aggregated data for the main dashboard page.
|
| 3 |
+
Returns balances, recent transactions, spending breakdown, and AI briefing.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from sqlalchemy import func, desc
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
|
| 11 |
+
from app.database.database import get_db
|
| 12 |
+
from app.database.models import User, Account, Transaction, AnalyticsSnapshot
|
| 13 |
+
from app.middleware.cache import cache
|
| 14 |
+
from app.ai.fraud import get_user_fraud_alerts
|
| 15 |
+
from collections import defaultdict
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
| 18 |
+
|
| 19 |
+
def _resolve_user(db: Session, user_id: Optional[str]) -> str:
|
| 20 |
+
if user_id:
|
| 21 |
+
return user_id
|
| 22 |
+
user = db.query(User).first()
|
| 23 |
+
if not user:
|
| 24 |
+
from fastapi import HTTPException
|
| 25 |
+
raise HTTPException(status_code=404, detail="No users found. Seed the database first.")
|
| 26 |
+
return user.id
|
| 27 |
+
|
| 28 |
+
@router.get("/overview")
|
| 29 |
+
def get_dashboard_overview(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 30 |
+
"""
|
| 31 |
+
Returns all data needed for the main dashboard in a single request:
|
| 32 |
+
- account balances
|
| 33 |
+
- monthly income/expense totals
|
| 34 |
+
- recent transactions (last 10)
|
| 35 |
+
- spending by category (current month)
|
| 36 |
+
- financial health score
|
| 37 |
+
- AI daily briefing (cached 1h)
|
| 38 |
+
- fraud alert count
|
| 39 |
+
"""
|
| 40 |
+
uid = _resolve_user(db, user_id)
|
| 41 |
+
cache_key = f"dashboard:overview:{uid}"
|
| 42 |
+
cached = cache.get(cache_key)
|
| 43 |
+
if cached:
|
| 44 |
+
return cached
|
| 45 |
+
|
| 46 |
+
# ββ Accounts & balances ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
accounts = db.query(Account).filter(Account.user_id == uid).all()
|
| 48 |
+
total_balance = sum(a.balance for a in accounts)
|
| 49 |
+
account_list = [
|
| 50 |
+
{"id": a.id, "type": a.type, "balance": a.balance, "currency": a.currency}
|
| 51 |
+
for a in accounts
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
# ββ Current month date range βββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
now = datetime.utcnow()
|
| 56 |
+
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 57 |
+
|
| 58 |
+
# ββ Transactions this month (lightweight) βββββββββββββββββββββββββββββββ
|
| 59 |
+
account_ids = [a.id for a in accounts]
|
| 60 |
+
monthly_raw = (
|
| 61 |
+
db.query(Transaction.type, Transaction.amount, Transaction.category)
|
| 62 |
+
.filter(
|
| 63 |
+
Transaction.account_id.in_(account_ids),
|
| 64 |
+
Transaction.timestamp >= month_start,
|
| 65 |
+
)
|
| 66 |
+
.all()
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
monthly_income = sum(amt for t_type, amt, _ in monthly_raw if t_type == "credit")
|
| 70 |
+
monthly_expenses = sum(abs(amt) for t_type, amt, _ in monthly_raw if t_type == "debit")
|
| 71 |
+
savings_rate = round((monthly_income - monthly_expenses) / monthly_income * 100, 1) if monthly_income > 0 else 0.0
|
| 72 |
+
|
| 73 |
+
# ββ Spending by category βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
category_totals: dict = {}
|
| 75 |
+
for t_type, amt, cat in monthly_raw:
|
| 76 |
+
if t_type == "debit" and cat:
|
| 77 |
+
category_totals[cat] = category_totals.get(cat, 0) + abs(amt)
|
| 78 |
+
|
| 79 |
+
spending_by_category = [
|
| 80 |
+
{"name": cat, "value": round(total, 2)}
|
| 81 |
+
for cat, total in sorted(category_totals.items(), key=lambda x: -x[1])
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
# ββ Recent transactions (last 10) ββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
recent_txns = (
|
| 86 |
+
db.query(Transaction)
|
| 87 |
+
.filter(Transaction.account_id.in_(account_ids))
|
| 88 |
+
.order_by(desc(Transaction.timestamp))
|
| 89 |
+
.limit(10)
|
| 90 |
+
.all()
|
| 91 |
+
)
|
| 92 |
+
recent_list = [
|
| 93 |
+
{
|
| 94 |
+
"id": t.id,
|
| 95 |
+
"merchant": t.merchant or "Unknown",
|
| 96 |
+
"category": t.category or "Other",
|
| 97 |
+
"amount": t.amount if t.type == "credit" else -abs(t.amount),
|
| 98 |
+
"type": t.type,
|
| 99 |
+
"timestamp": t.timestamp.isoformat() if t.timestamp else None,
|
| 100 |
+
}
|
| 101 |
+
for t in recent_txns
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
# ββ 6-month cash flow trend (lightweight column-only query) βββββββββββββ
|
| 105 |
+
six_months_ago = now - timedelta(days=180)
|
| 106 |
+
raw_6m = (
|
| 107 |
+
db.query(
|
| 108 |
+
Transaction.type,
|
| 109 |
+
Transaction.amount,
|
| 110 |
+
Transaction.timestamp,
|
| 111 |
+
)
|
| 112 |
+
.filter(
|
| 113 |
+
Transaction.account_id.in_(account_ids),
|
| 114 |
+
Transaction.timestamp >= six_months_ago,
|
| 115 |
+
)
|
| 116 |
+
.all()
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# Group by month label in Python
|
| 120 |
+
month_buckets: dict = defaultdict(lambda: {"income": 0.0, "expenses": 0.0})
|
| 121 |
+
for t_type, t_amount, t_ts in raw_6m:
|
| 122 |
+
if t_ts:
|
| 123 |
+
label = t_ts.strftime("%b")
|
| 124 |
+
if t_type == "credit":
|
| 125 |
+
month_buckets[label]["income"] += t_amount
|
| 126 |
+
else:
|
| 127 |
+
month_buckets[label]["expenses"] += abs(t_amount)
|
| 128 |
+
|
| 129 |
+
# Build ordered list for last 6 months
|
| 130 |
+
cash_flow = []
|
| 131 |
+
for i in range(5, -1, -1):
|
| 132 |
+
m_date = (now.replace(day=1) - timedelta(days=i * 30))
|
| 133 |
+
label = m_date.strftime("%b")
|
| 134 |
+
inc = round(month_buckets[label]["income"], 2)
|
| 135 |
+
exp = round(month_buckets[label]["expenses"], 2)
|
| 136 |
+
cash_flow.append({
|
| 137 |
+
"month": label,
|
| 138 |
+
"income": inc,
|
| 139 |
+
"expenses": exp,
|
| 140 |
+
"savings": round(max(inc - exp, 0), 2),
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
# ββ Financial health score (from cache only β never block on AI) ββββββββββββ
|
| 144 |
+
score_data = {}
|
| 145 |
+
health_score = 0.0
|
| 146 |
+
try:
|
| 147 |
+
score_cache_key = f"ai:coaching:score:{uid}"
|
| 148 |
+
score_data = cache.get(score_cache_key) or {}
|
| 149 |
+
health_score = score_data.get("overall_score", 0.0)
|
| 150 |
+
except Exception:
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
+
# ββ Fraud alerts (cached separately) ββββββββββββββββββββββββββββββββββββ
|
| 154 |
+
fraud_count = 0
|
| 155 |
+
try:
|
| 156 |
+
fraud_cache_key = f"dashboard:fraud:{uid}"
|
| 157 |
+
cached_fraud = cache.get(fraud_cache_key)
|
| 158 |
+
if cached_fraud is not None:
|
| 159 |
+
fraud_count = cached_fraud
|
| 160 |
+
else:
|
| 161 |
+
fraud_data = get_user_fraud_alerts(db, uid)
|
| 162 |
+
fraud_count = len(fraud_data.get("alerts", []))
|
| 163 |
+
cache.set(fraud_cache_key, fraud_count, ttl=300) # 5-min cache
|
| 164 |
+
except Exception:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
# ββ AI briefing (from cache only β never block on AI) ββββββββββββββββββββ
|
| 168 |
+
briefing_key = f"ai:coaching:briefing:{uid}"
|
| 169 |
+
briefing = cache.get(briefing_key) or {
|
| 170 |
+
"summary": "Run /api/ai/coaching/briefing to generate your AI daily briefing.",
|
| 171 |
+
"briefing": None,
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
result = {
|
| 175 |
+
"total_balance": round(total_balance, 2),
|
| 176 |
+
"accounts": account_list,
|
| 177 |
+
"monthly_income": round(monthly_income, 2),
|
| 178 |
+
"monthly_expenses": round(monthly_expenses, 2),
|
| 179 |
+
"savings_rate": savings_rate,
|
| 180 |
+
"spending_by_category": spending_by_category,
|
| 181 |
+
"recent_transactions": recent_list,
|
| 182 |
+
"cash_flow": cash_flow,
|
| 183 |
+
"health_score": round(health_score, 1),
|
| 184 |
+
"fraud_alert_count": fraud_count,
|
| 185 |
+
"ai_briefing": briefing,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
cache.set(cache_key, result, ttl=120) # 2-minute cache
|
| 189 |
+
return result
|
backend/app/database/database.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
from sqlalchemy.exc import OperationalError
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# Read database URL from environment or fallback to docker-compose default
|
| 8 |
+
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://admin:adminpassword@localhost:5432/bankbot")
|
| 9 |
+
USE_SQLITE = os.getenv("USE_SQLITE", "false").lower() in ("true", "1", "yes")
|
| 10 |
+
|
| 11 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
sqlite_db_path = os.path.join(BASE_DIR, "bankbot.db")
|
| 13 |
+
|
| 14 |
+
if USE_SQLITE:
|
| 15 |
+
SQLALCHEMY_DATABASE_URL = f"sqlite:///{sqlite_db_path}"
|
| 16 |
+
|
| 17 |
+
connect_args = {}
|
| 18 |
+
if "sqlite" in SQLALCHEMY_DATABASE_URL:
|
| 19 |
+
connect_args = {"check_same_thread": False}
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
|
| 23 |
+
# Test connection
|
| 24 |
+
with engine.connect() as conn:
|
| 25 |
+
pass
|
| 26 |
+
except (OperationalError, Exception) as e:
|
| 27 |
+
print(f"Database connection to {SQLALCHEMY_DATABASE_URL} failed: {e}")
|
| 28 |
+
print(f"Falling back to SQLite database at sqlite:///{sqlite_db_path}...")
|
| 29 |
+
SQLALCHEMY_DATABASE_URL = f"sqlite:///{sqlite_db_path}"
|
| 30 |
+
connect_args = {"check_same_thread": False}
|
| 31 |
+
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
|
| 32 |
+
|
| 33 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 34 |
+
|
| 35 |
+
Base = declarative_base()
|
| 36 |
+
|
| 37 |
+
def get_db():
|
| 38 |
+
db = SessionLocal()
|
| 39 |
+
try:
|
| 40 |
+
yield db
|
| 41 |
+
finally:
|
| 42 |
+
db.close()
|
backend/app/database/models.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from sqlalchemy.sql import func
|
| 4 |
+
from app.database.database import Base
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
def generate_uuid():
|
| 8 |
+
return str(uuid.uuid4())
|
| 9 |
+
|
| 10 |
+
class User(Base):
|
| 11 |
+
__tablename__ = "users"
|
| 12 |
+
|
| 13 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 14 |
+
email = Column(String, unique=True, index=True, nullable=False)
|
| 15 |
+
password_hash = Column(String, nullable=False)
|
| 16 |
+
profile_data = Column(JSON, default={})
|
| 17 |
+
financial_personality = Column(String, default="Unknown")
|
| 18 |
+
ai_personalization_settings = Column(JSON, default={})
|
| 19 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 20 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 21 |
+
|
| 22 |
+
accounts = relationship("Account", back_populates="user")
|
| 23 |
+
subscriptions = relationship("Subscription", back_populates="user")
|
| 24 |
+
goals = relationship("Goal", back_populates="user")
|
| 25 |
+
investments = relationship("Investment", back_populates="user")
|
| 26 |
+
ai_insights = relationship("AIInsight", back_populates="user")
|
| 27 |
+
notifications = relationship("Notification", back_populates="user")
|
| 28 |
+
analytics_snapshots = relationship("AnalyticsSnapshot", back_populates="user")
|
| 29 |
+
|
| 30 |
+
class Account(Base):
|
| 31 |
+
__tablename__ = "accounts"
|
| 32 |
+
|
| 33 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 34 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 35 |
+
type = Column(String, nullable=False) # e.g. checking, savings
|
| 36 |
+
balance = Column(Float, default=0.0)
|
| 37 |
+
currency = Column(String, default="USD")
|
| 38 |
+
status = Column(String, default="active")
|
| 39 |
+
|
| 40 |
+
user = relationship("User", back_populates="accounts")
|
| 41 |
+
transactions = relationship("Transaction", back_populates="account")
|
| 42 |
+
|
| 43 |
+
class Transaction(Base):
|
| 44 |
+
__tablename__ = "transactions"
|
| 45 |
+
|
| 46 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 47 |
+
account_id = Column(String, ForeignKey("accounts.id"), nullable=False)
|
| 48 |
+
amount = Column(Float, nullable=False)
|
| 49 |
+
type = Column(String, nullable=False) # credit, debit
|
| 50 |
+
category = Column(String)
|
| 51 |
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
| 52 |
+
merchant = Column(String)
|
| 53 |
+
tags = Column(JSON, default=[])
|
| 54 |
+
ai_generated_metadata = Column(JSON, default={})
|
| 55 |
+
spending_emotion_label = Column(String)
|
| 56 |
+
|
| 57 |
+
account = relationship("Account", back_populates="transactions")
|
| 58 |
+
fraud_log = relationship("FraudLog", back_populates="transaction", uselist=False)
|
| 59 |
+
|
| 60 |
+
class Subscription(Base):
|
| 61 |
+
__tablename__ = "subscriptions"
|
| 62 |
+
|
| 63 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 64 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 65 |
+
merchant = Column(String, nullable=False)
|
| 66 |
+
amount = Column(Float, nullable=False)
|
| 67 |
+
billing_cycle = Column(String, nullable=False) # monthly, yearly
|
| 68 |
+
active = Column(Boolean, default=True)
|
| 69 |
+
ai_usage_detection = Column(JSON, default={})
|
| 70 |
+
|
| 71 |
+
user = relationship("User", back_populates="subscriptions")
|
| 72 |
+
|
| 73 |
+
class Goal(Base):
|
| 74 |
+
__tablename__ = "goals"
|
| 75 |
+
|
| 76 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 77 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 78 |
+
title = Column(String, nullable=False)
|
| 79 |
+
target_amount = Column(Float, nullable=False)
|
| 80 |
+
current_amount = Column(Float, default=0.0)
|
| 81 |
+
target_date = Column(DateTime(timezone=True))
|
| 82 |
+
ai_generated_plan = Column(JSON, default={})
|
| 83 |
+
|
| 84 |
+
user = relationship("User", back_populates="goals")
|
| 85 |
+
|
| 86 |
+
class Investment(Base):
|
| 87 |
+
__tablename__ = "investments"
|
| 88 |
+
|
| 89 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 90 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 91 |
+
asset_name = Column(String, nullable=False)
|
| 92 |
+
type = Column(String, nullable=False) # stock, crypto, mutual_fund
|
| 93 |
+
amount_invested = Column(Float, default=0.0)
|
| 94 |
+
current_value = Column(Float, default=0.0)
|
| 95 |
+
portfolio_allocation = Column(Float, default=0.0)
|
| 96 |
+
ai_risk_analysis = Column(JSON, default={})
|
| 97 |
+
|
| 98 |
+
user = relationship("User", back_populates="investments")
|
| 99 |
+
|
| 100 |
+
class AIInsight(Base):
|
| 101 |
+
__tablename__ = "ai_insights"
|
| 102 |
+
|
| 103 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 104 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 105 |
+
type = Column(String, nullable=False) # recommendation, briefing, cashflow
|
| 106 |
+
content = Column(Text, nullable=False)
|
| 107 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 108 |
+
|
| 109 |
+
user = relationship("User", back_populates="ai_insights")
|
| 110 |
+
|
| 111 |
+
class FraudLog(Base):
|
| 112 |
+
__tablename__ = "fraud_logs"
|
| 113 |
+
|
| 114 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 115 |
+
transaction_id = Column(String, ForeignKey("transactions.id"), nullable=False)
|
| 116 |
+
risk_score = Column(Float, nullable=False)
|
| 117 |
+
suspicious_activity_details = Column(Text)
|
| 118 |
+
status = Column(String, default="pending") # pending, resolved, false_positive
|
| 119 |
+
|
| 120 |
+
transaction = relationship("Transaction", back_populates="fraud_log")
|
| 121 |
+
|
| 122 |
+
class Notification(Base):
|
| 123 |
+
__tablename__ = "notifications"
|
| 124 |
+
|
| 125 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 126 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 127 |
+
title = Column(String, nullable=False)
|
| 128 |
+
message = Column(Text, nullable=False)
|
| 129 |
+
type = Column(String, nullable=False) # alert, insight, warning
|
| 130 |
+
read_status = Column(Boolean, default=False)
|
| 131 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 132 |
+
|
| 133 |
+
user = relationship("User", back_populates="notifications")
|
| 134 |
+
|
| 135 |
+
class AnalyticsSnapshot(Base):
|
| 136 |
+
__tablename__ = "analytics_snapshots"
|
| 137 |
+
|
| 138 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 139 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 140 |
+
date = Column(DateTime(timezone=True), nullable=False)
|
| 141 |
+
total_balance = Column(Float, default=0.0)
|
| 142 |
+
total_spending = Column(Float, default=0.0)
|
| 143 |
+
total_savings = Column(Float, default=0.0)
|
| 144 |
+
financial_score = Column(Float, default=0.0)
|
| 145 |
+
trends_json = Column(JSON, default={})
|
| 146 |
+
|
| 147 |
+
user = relationship("User", back_populates="analytics_snapshots")
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BankBot FastAPI β production entry point.
|
| 3 |
+
Phase 7: structured logging, metrics, security headers, rate limiting.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, Request, Response
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from fastapi.responses import JSONResponse
|
| 13 |
+
|
| 14 |
+
from app.database.database import engine, Base
|
| 15 |
+
import app.database.models # noqa: F401
|
| 16 |
+
|
| 17 |
+
# βββ Routers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
from app.ai.router import router as ai_router
|
| 19 |
+
from app.websocket.router import router as ws_router
|
| 20 |
+
from app.auth.router import router as auth_router
|
| 21 |
+
from app.dashboard.router import router as dashboard_router
|
| 22 |
+
from app.notifications.router import router as notifications_router
|
| 23 |
+
from app.transactions.router import router as transactions_router
|
| 24 |
+
|
| 25 |
+
# βββ Observability ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
from app.middleware.logging import RequestLoggingMiddleware, metrics, api_logger
|
| 27 |
+
|
| 28 |
+
# βββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
app = FastAPI(
|
| 30 |
+
title="BankBot AI API",
|
| 31 |
+
description="Production-grade AI-powered financial platform",
|
| 32 |
+
version="2.0.0",
|
| 33 |
+
docs_url="/docs",
|
| 34 |
+
redoc_url="/redoc",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# βββ CORS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
_raw = os.environ.get("BACKEND_CORS_ORIGINS", '["http://localhost:3000","http://localhost:7860"]')
|
| 39 |
+
try:
|
| 40 |
+
allowed_origins = json.loads(_raw)
|
| 41 |
+
except Exception:
|
| 42 |
+
allowed_origins = ["http://localhost:3000", "http://localhost:7860"]
|
| 43 |
+
|
| 44 |
+
# In HF Spaces, the Space URL is dynamic β allow all *.hf.space origins
|
| 45 |
+
# by using allow_origin_regex as a fallback
|
| 46 |
+
HF_SPACE_PATTERN = r"https://.*\.hf\.space"
|
| 47 |
+
|
| 48 |
+
app.add_middleware(
|
| 49 |
+
CORSMiddleware,
|
| 50 |
+
allow_origins=allowed_origins,
|
| 51 |
+
allow_origin_regex=HF_SPACE_PATTERN,
|
| 52 |
+
allow_credentials=True,
|
| 53 |
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
| 54 |
+
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
| 55 |
+
expose_headers=["X-Request-ID", "X-Process-Time"],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# βββ Request logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
app.add_middleware(RequestLoggingMiddleware)
|
| 60 |
+
|
| 61 |
+
# βββ Security headers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
@app.middleware("http")
|
| 63 |
+
async def security_headers(request: Request, call_next):
|
| 64 |
+
response: Response = await call_next(request)
|
| 65 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 66 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 67 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 68 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 69 |
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
| 70 |
+
return response
|
| 71 |
+
|
| 72 |
+
# βββ Process-time header ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
@app.middleware("http")
|
| 74 |
+
async def process_time_header(request: Request, call_next):
|
| 75 |
+
t = time.time()
|
| 76 |
+
response = await call_next(request)
|
| 77 |
+
response.headers["X-Process-Time"] = f"{(time.time()-t)*1000:.1f}ms"
|
| 78 |
+
return response
|
| 79 |
+
|
| 80 |
+
# βββ Rate limiter βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
_rate_store: dict = defaultdict(list)
|
| 82 |
+
RATE_LIMIT = 120
|
| 83 |
+
RATE_WINDOW = 60
|
| 84 |
+
|
| 85 |
+
@app.middleware("http")
|
| 86 |
+
async def rate_limit(request: Request, call_next):
|
| 87 |
+
skip = request.url.path in ("/health", "/") or \
|
| 88 |
+
"websocket" in request.headers.get("upgrade", "").lower()
|
| 89 |
+
if skip:
|
| 90 |
+
return await call_next(request)
|
| 91 |
+
|
| 92 |
+
ip = request.client.host if request.client else "unknown"
|
| 93 |
+
now = time.time()
|
| 94 |
+
_rate_store[ip] = [t for t in _rate_store[ip] if t > now - RATE_WINDOW]
|
| 95 |
+
|
| 96 |
+
if len(_rate_store[ip]) >= RATE_LIMIT:
|
| 97 |
+
metrics.record_error(request.url.path, 429, "rate_limited")
|
| 98 |
+
return JSONResponse(
|
| 99 |
+
status_code=429,
|
| 100 |
+
content={"detail": "Too many requests. Please slow down."},
|
| 101 |
+
headers={"Retry-After": str(RATE_WINDOW)},
|
| 102 |
+
)
|
| 103 |
+
_rate_store[ip].append(now)
|
| 104 |
+
return await call_next(request)
|
| 105 |
+
|
| 106 |
+
# βββ Startup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 107 |
+
@app.on_event("startup")
|
| 108 |
+
def startup():
|
| 109 |
+
api_logger.info("BankBot API starting", extra={"version": "2.0.0"})
|
| 110 |
+
Base.metadata.create_all(bind=engine)
|
| 111 |
+
api_logger.info("Database tables ready")
|
| 112 |
+
|
| 113 |
+
# Log active backends
|
| 114 |
+
from app.ai.ollama_integration import OPENAI_API_KEY, GROQ_API_KEY, has_active_ai_backend
|
| 115 |
+
from app.middleware.cache import cache
|
| 116 |
+
from app.database.database import SQLALCHEMY_DATABASE_URL
|
| 117 |
+
|
| 118 |
+
ai_backend = "openai" if OPENAI_API_KEY else ("groq" if GROQ_API_KEY else "ollama")
|
| 119 |
+
api_logger.info("Startup diagnostics", extra={
|
| 120 |
+
"ai_backend": ai_backend,
|
| 121 |
+
"ai_available": has_active_ai_backend(),
|
| 122 |
+
"db_type": "sqlite" if "sqlite" in SQLALCHEMY_DATABASE_URL else "postgresql",
|
| 123 |
+
"cache_type": "redis" if cache.use_redis else "memory",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
# βββ Routers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
+
app.include_router(auth_router)
|
| 128 |
+
app.include_router(ai_router)
|
| 129 |
+
app.include_router(ws_router)
|
| 130 |
+
app.include_router(dashboard_router)
|
| 131 |
+
app.include_router(notifications_router)
|
| 132 |
+
app.include_router(transactions_router)
|
| 133 |
+
|
| 134 |
+
# βββ Core endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
@app.get("/", tags=["Core"])
|
| 136 |
+
def root():
|
| 137 |
+
return {"message": "BankBot AI API v2.0", "status": "operational", "docs": "/docs"}
|
| 138 |
+
|
| 139 |
+
@app.get("/health", tags=["Core"])
|
| 140 |
+
def health():
|
| 141 |
+
from app.middleware.cache import cache
|
| 142 |
+
from app.database.database import SQLALCHEMY_DATABASE_URL
|
| 143 |
+
return {
|
| 144 |
+
"status": "healthy",
|
| 145 |
+
"timestamp": time.time(),
|
| 146 |
+
"db": "sqlite" if "sqlite" in SQLALCHEMY_DATABASE_URL else "postgresql",
|
| 147 |
+
"cache": "redis" if cache.use_redis else "memory",
|
| 148 |
+
"uptime_s": round(time.time() - metrics.start_time, 0),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
@app.get("/api/status", tags=["Core"])
|
| 152 |
+
def api_status():
|
| 153 |
+
from app.ai.ollama_integration import has_active_ai_backend, OPENAI_API_KEY, GROQ_API_KEY
|
| 154 |
+
from app.middleware.cache import cache
|
| 155 |
+
from app.database.database import SQLALCHEMY_DATABASE_URL
|
| 156 |
+
ai = "openai" if OPENAI_API_KEY else ("groq" if GROQ_API_KEY else "ollama")
|
| 157 |
+
return {
|
| 158 |
+
"ai_backend": ai,
|
| 159 |
+
"ai_available": has_active_ai_backend(),
|
| 160 |
+
"db_type": "sqlite" if "sqlite" in SQLALCHEMY_DATABASE_URL else "postgresql",
|
| 161 |
+
"cache_type": "redis" if cache.use_redis else "memory",
|
| 162 |
+
"version": "2.0.0",
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
@app.get("/api/metrics", tags=["Observability"])
|
| 166 |
+
def get_metrics():
|
| 167 |
+
"""
|
| 168 |
+
Live observability dashboard β request counts, AI latency,
|
| 169 |
+
cache hit ratio, WebSocket stats, recent errors.
|
| 170 |
+
"""
|
| 171 |
+
return metrics.summary()
|
backend/app/middleware/__init__.py
ADDED
|
File without changes
|
backend/app/middleware/cache.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
from threading import Lock
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
import redis
|
| 8 |
+
except ImportError:
|
| 9 |
+
redis = None
|
| 10 |
+
|
| 11 |
+
class MemoryCache:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self._cache = {}
|
| 14 |
+
self._lock = Lock()
|
| 15 |
+
|
| 16 |
+
def get(self, key):
|
| 17 |
+
with self._lock:
|
| 18 |
+
if key not in self._cache:
|
| 19 |
+
return None
|
| 20 |
+
val, expiry = self._cache[key]
|
| 21 |
+
if expiry is not None and time.time() > expiry:
|
| 22 |
+
del self._cache[key]
|
| 23 |
+
return None
|
| 24 |
+
return val
|
| 25 |
+
|
| 26 |
+
def set(self, key, value, ttl=None):
|
| 27 |
+
with self._lock:
|
| 28 |
+
expiry = time.time() + ttl if ttl is not None else None
|
| 29 |
+
self._cache[key] = (value, expiry)
|
| 30 |
+
|
| 31 |
+
def delete(self, key):
|
| 32 |
+
with self._lock:
|
| 33 |
+
if key in self._cache:
|
| 34 |
+
del self._cache[key]
|
| 35 |
+
|
| 36 |
+
class CacheManager:
|
| 37 |
+
def __init__(self):
|
| 38 |
+
self.redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
| 39 |
+
self.redis_client = None
|
| 40 |
+
self.use_redis = False
|
| 41 |
+
|
| 42 |
+
if redis is not None:
|
| 43 |
+
try:
|
| 44 |
+
self.redis_client = redis.Redis.from_url(self.redis_url, socket_timeout=1.0)
|
| 45 |
+
# Test connection
|
| 46 |
+
self.redis_client.ping()
|
| 47 |
+
self.use_redis = True
|
| 48 |
+
print("Connected to Redis successfully.")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"Redis connection failed ({e}). Falling back to in-memory cache.")
|
| 51 |
+
else:
|
| 52 |
+
print("Redis library not installed. Falling back to in-memory cache.")
|
| 53 |
+
|
| 54 |
+
self.memory_cache = MemoryCache()
|
| 55 |
+
|
| 56 |
+
def get(self, key: str):
|
| 57 |
+
if self.use_redis:
|
| 58 |
+
try:
|
| 59 |
+
val = self.redis_client.get(key)
|
| 60 |
+
if val:
|
| 61 |
+
return json.loads(val.decode('utf-8'))
|
| 62 |
+
except Exception as e:
|
| 63 |
+
# Fallback to memory on Redis error during operation
|
| 64 |
+
print(f"Redis get failed ({e}). Using memory cache fallback.")
|
| 65 |
+
return self.memory_cache.get(key)
|
| 66 |
+
|
| 67 |
+
def set(self, key: str, value, ttl: int = None):
|
| 68 |
+
if self.use_redis:
|
| 69 |
+
try:
|
| 70 |
+
self.redis_client.set(key, json.dumps(value), ex=ttl)
|
| 71 |
+
return
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"Redis set failed ({e}). Using memory cache fallback.")
|
| 74 |
+
self.memory_cache.set(key, value, ttl)
|
| 75 |
+
|
| 76 |
+
def delete(self, key: str):
|
| 77 |
+
if self.use_redis:
|
| 78 |
+
try:
|
| 79 |
+
self.redis_client.delete(key)
|
| 80 |
+
return
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"Redis delete failed ({e}). Using memory cache fallback.")
|
| 83 |
+
self.memory_cache.delete(key)
|
| 84 |
+
|
| 85 |
+
# Global cache instance
|
| 86 |
+
cache = CacheManager()
|
backend/app/middleware/logging.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Structured logging middleware β JSON logs with request tracing,
|
| 3 |
+
timing, AI provider health, cache hit ratios, and WebSocket events.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
import uuid
|
| 9 |
+
from collections import defaultdict, deque
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Callable
|
| 12 |
+
|
| 13 |
+
from fastapi import Request, Response
|
| 14 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 15 |
+
|
| 16 |
+
# βββ Structured JSON logger βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
class JSONFormatter(logging.Formatter):
|
| 18 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 19 |
+
log = {
|
| 20 |
+
"ts": datetime.utcnow().isoformat() + "Z",
|
| 21 |
+
"level": record.levelname,
|
| 22 |
+
"logger": record.name,
|
| 23 |
+
"msg": record.getMessage(),
|
| 24 |
+
}
|
| 25 |
+
if hasattr(record, "extra"):
|
| 26 |
+
log.update(record.extra)
|
| 27 |
+
if record.exc_info:
|
| 28 |
+
log["exc"] = self.formatException(record.exc_info)
|
| 29 |
+
return json.dumps(log)
|
| 30 |
+
|
| 31 |
+
def get_logger(name: str) -> logging.Logger:
|
| 32 |
+
logger = logging.getLogger(name)
|
| 33 |
+
if not logger.handlers:
|
| 34 |
+
handler = logging.StreamHandler()
|
| 35 |
+
handler.setFormatter(JSONFormatter())
|
| 36 |
+
logger.addHandler(handler)
|
| 37 |
+
logger.setLevel(logging.INFO)
|
| 38 |
+
logger.propagate = False
|
| 39 |
+
return logger
|
| 40 |
+
|
| 41 |
+
api_logger = get_logger("bankbot.api")
|
| 42 |
+
ai_logger = get_logger("bankbot.ai")
|
| 43 |
+
ws_logger = get_logger("bankbot.ws")
|
| 44 |
+
db_logger = get_logger("bankbot.db")
|
| 45 |
+
|
| 46 |
+
# βββ In-process metrics store βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
class MetricsStore:
|
| 48 |
+
"""Thread-safe in-memory metrics β no external dependency."""
|
| 49 |
+
|
| 50 |
+
def __init__(self):
|
| 51 |
+
self.request_count: int = 0
|
| 52 |
+
self.error_count: int = 0
|
| 53 |
+
self.auth_failures: int = 0
|
| 54 |
+
self.ws_connects: int = 0
|
| 55 |
+
self.ws_reconnects: int = 0
|
| 56 |
+
self.ai_calls: dict = defaultdict(int) # provider β count
|
| 57 |
+
self.ai_errors: dict = defaultdict(int) # provider β errors
|
| 58 |
+
self.ai_latencies: dict = defaultdict(list) # provider β [ms]
|
| 59 |
+
self.ai_fallbacks: int = 0
|
| 60 |
+
self.cache_hits: int = 0
|
| 61 |
+
self.cache_misses: int = 0
|
| 62 |
+
self.route_timings: dict = defaultdict(list) # path β [ms]
|
| 63 |
+
self._recent_errors: deque = deque(maxlen=50) # last 50 errors
|
| 64 |
+
self.start_time: float = time.time()
|
| 65 |
+
|
| 66 |
+
# ββ AI tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
def record_ai_call(self, provider: str, latency_ms: float, success: bool):
|
| 68 |
+
self.ai_calls[provider] += 1
|
| 69 |
+
self.ai_latencies[provider].append(latency_ms)
|
| 70 |
+
if len(self.ai_latencies[provider]) > 200:
|
| 71 |
+
self.ai_latencies[provider] = self.ai_latencies[provider][-200:]
|
| 72 |
+
if not success:
|
| 73 |
+
self.ai_errors[provider] += 1
|
| 74 |
+
|
| 75 |
+
def record_ai_fallback(self):
|
| 76 |
+
self.ai_fallbacks += 1
|
| 77 |
+
|
| 78 |
+
# ββ Cache tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
def record_cache_hit(self):
|
| 80 |
+
self.cache_hits += 1
|
| 81 |
+
|
| 82 |
+
def record_cache_miss(self):
|
| 83 |
+
self.cache_misses += 1
|
| 84 |
+
|
| 85 |
+
# ββ Error tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
def record_error(self, path: str, status: int, detail: str):
|
| 87 |
+
self._recent_errors.append({
|
| 88 |
+
"ts": datetime.utcnow().isoformat() + "Z",
|
| 89 |
+
"path": path,
|
| 90 |
+
"status": status,
|
| 91 |
+
"detail": detail[:200],
|
| 92 |
+
})
|
| 93 |
+
self.error_count += 1
|
| 94 |
+
if status == 401:
|
| 95 |
+
self.auth_failures += 1
|
| 96 |
+
|
| 97 |
+
# ββ Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
def summary(self) -> dict:
|
| 99 |
+
uptime = time.time() - self.start_time
|
| 100 |
+
cache_total = self.cache_hits + self.cache_misses
|
| 101 |
+
cache_ratio = round(self.cache_hits / cache_total * 100, 1) if cache_total else 0
|
| 102 |
+
|
| 103 |
+
ai_summary = {}
|
| 104 |
+
for provider in set(list(self.ai_calls.keys()) + list(self.ai_errors.keys())):
|
| 105 |
+
lats = self.ai_latencies.get(provider, [])
|
| 106 |
+
ai_summary[provider] = {
|
| 107 |
+
"calls": self.ai_calls[provider],
|
| 108 |
+
"errors": self.ai_errors[provider],
|
| 109 |
+
"avg_latency_ms": round(sum(lats) / len(lats), 1) if lats else 0,
|
| 110 |
+
"p95_latency_ms": round(sorted(lats)[int(len(lats) * 0.95)], 1) if len(lats) >= 20 else None,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
slow_routes = {}
|
| 114 |
+
for path, times in self.route_timings.items():
|
| 115 |
+
if times:
|
| 116 |
+
slow_routes[path] = {
|
| 117 |
+
"calls": len(times),
|
| 118 |
+
"avg_ms": round(sum(times) / len(times), 1),
|
| 119 |
+
"max_ms": round(max(times), 1),
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"uptime_seconds": round(uptime, 0),
|
| 124 |
+
"requests": {
|
| 125 |
+
"total": self.request_count,
|
| 126 |
+
"errors": self.error_count,
|
| 127 |
+
"auth_failures": self.auth_failures,
|
| 128 |
+
"error_rate_pct": round(self.error_count / max(self.request_count, 1) * 100, 2),
|
| 129 |
+
},
|
| 130 |
+
"websocket": {
|
| 131 |
+
"total_connects": self.ws_connects,
|
| 132 |
+
"reconnects": self.ws_reconnects,
|
| 133 |
+
},
|
| 134 |
+
"ai": {
|
| 135 |
+
"fallbacks": self.ai_fallbacks,
|
| 136 |
+
"by_provider": ai_summary,
|
| 137 |
+
},
|
| 138 |
+
"cache": {
|
| 139 |
+
"hits": self.cache_hits,
|
| 140 |
+
"misses": self.cache_misses,
|
| 141 |
+
"hit_ratio_pct": cache_ratio,
|
| 142 |
+
},
|
| 143 |
+
"route_timings": dict(sorted(slow_routes.items(), key=lambda x: -x[1]["avg_ms"])[:10]),
|
| 144 |
+
"recent_errors": list(self._recent_errors)[-10:],
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
metrics = MetricsStore()
|
| 148 |
+
|
| 149 |
+
# βββ Request logging middleware βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
| 151 |
+
SKIP_PATHS = {"/health", "/openapi.json", "/docs", "/redoc", "/docs/oauth2-redirect"}
|
| 152 |
+
|
| 153 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 154 |
+
if request.url.path in self.SKIP_PATHS:
|
| 155 |
+
return await call_next(request)
|
| 156 |
+
|
| 157 |
+
request_id = str(uuid.uuid4())[:8]
|
| 158 |
+
start = time.time()
|
| 159 |
+
metrics.request_count += 1
|
| 160 |
+
|
| 161 |
+
response = await call_next(request)
|
| 162 |
+
|
| 163 |
+
elapsed_ms = (time.time() - start) * 1000
|
| 164 |
+
path = request.url.path
|
| 165 |
+
metrics.route_timings[path].append(elapsed_ms)
|
| 166 |
+
if len(metrics.route_timings[path]) > 500:
|
| 167 |
+
metrics.route_timings[path] = metrics.route_timings[path][-500:]
|
| 168 |
+
|
| 169 |
+
level = logging.WARNING if elapsed_ms > 2000 else logging.INFO
|
| 170 |
+
if response.status_code >= 400:
|
| 171 |
+
metrics.record_error(path, response.status_code, "")
|
| 172 |
+
level = logging.WARNING if response.status_code < 500 else logging.ERROR
|
| 173 |
+
|
| 174 |
+
api_logger.log(level, f"{request.method} {path}", extra={
|
| 175 |
+
"request_id": request_id,
|
| 176 |
+
"method": request.method,
|
| 177 |
+
"path": path,
|
| 178 |
+
"status": response.status_code,
|
| 179 |
+
"duration_ms": round(elapsed_ms, 1),
|
| 180 |
+
"ip": request.client.host if request.client else "unknown",
|
| 181 |
+
})
|
| 182 |
+
|
| 183 |
+
response.headers["X-Request-ID"] = request_id
|
| 184 |
+
return response
|
backend/app/notifications/__init__.py
ADDED
|
File without changes
|
backend/app/notifications/router.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Notifications router β CRUD for user notifications with WebSocket push support.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from sqlalchemy import desc
|
| 8 |
+
|
| 9 |
+
from app.database.database import get_db
|
| 10 |
+
from app.database.models import Notification, User, generate_uuid
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/notifications", tags=["Notifications"])
|
| 14 |
+
|
| 15 |
+
def _resolve_user(db: Session, user_id: Optional[str]) -> str:
|
| 16 |
+
if user_id:
|
| 17 |
+
return user_id
|
| 18 |
+
user = db.query(User).first()
|
| 19 |
+
if not user:
|
| 20 |
+
raise HTTPException(status_code=404, detail="No users found.")
|
| 21 |
+
return user.id
|
| 22 |
+
|
| 23 |
+
@router.get("/")
|
| 24 |
+
def get_notifications(user_id: Optional[str] = None, limit: int = 20, db: Session = Depends(get_db)):
|
| 25 |
+
uid = _resolve_user(db, user_id)
|
| 26 |
+
notifications = (
|
| 27 |
+
db.query(Notification)
|
| 28 |
+
.filter(Notification.user_id == uid)
|
| 29 |
+
.order_by(desc(Notification.created_at))
|
| 30 |
+
.limit(limit)
|
| 31 |
+
.all()
|
| 32 |
+
)
|
| 33 |
+
return {
|
| 34 |
+
"notifications": [
|
| 35 |
+
{
|
| 36 |
+
"id": n.id,
|
| 37 |
+
"title": n.title,
|
| 38 |
+
"message": n.message,
|
| 39 |
+
"type": n.type,
|
| 40 |
+
"read": n.read_status,
|
| 41 |
+
"created_at": n.created_at.isoformat() if n.created_at else None,
|
| 42 |
+
}
|
| 43 |
+
for n in notifications
|
| 44 |
+
],
|
| 45 |
+
"unread_count": sum(1 for n in notifications if not n.read_status),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
@router.patch("/{notification_id}/read")
|
| 49 |
+
def mark_notification_read(
|
| 50 |
+
notification_id: str,
|
| 51 |
+
user_id: Optional[str] = None,
|
| 52 |
+
db: Session = Depends(get_db)
|
| 53 |
+
):
|
| 54 |
+
uid = _resolve_user(db, user_id)
|
| 55 |
+
notif = db.query(Notification).filter(
|
| 56 |
+
Notification.id == notification_id,
|
| 57 |
+
Notification.user_id == uid
|
| 58 |
+
).first()
|
| 59 |
+
if not notif:
|
| 60 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 61 |
+
notif.read_status = True
|
| 62 |
+
db.commit()
|
| 63 |
+
return {"success": True}
|
| 64 |
+
|
| 65 |
+
@router.patch("/read-all")
|
| 66 |
+
def mark_all_read(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 67 |
+
uid = _resolve_user(db, user_id)
|
| 68 |
+
db.query(Notification).filter(
|
| 69 |
+
Notification.user_id == uid,
|
| 70 |
+
Notification.read_status == False
|
| 71 |
+
).update({"read_status": True})
|
| 72 |
+
db.commit()
|
| 73 |
+
return {"success": True}
|
| 74 |
+
|
| 75 |
+
@router.delete("/{notification_id}")
|
| 76 |
+
def delete_notification(
|
| 77 |
+
notification_id: str,
|
| 78 |
+
user_id: Optional[str] = None,
|
| 79 |
+
db: Session = Depends(get_db)
|
| 80 |
+
):
|
| 81 |
+
uid = _resolve_user(db, user_id)
|
| 82 |
+
notif = db.query(Notification).filter(
|
| 83 |
+
Notification.id == notification_id,
|
| 84 |
+
Notification.user_id == uid
|
| 85 |
+
).first()
|
| 86 |
+
if not notif:
|
| 87 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 88 |
+
db.delete(notif)
|
| 89 |
+
db.commit()
|
| 90 |
+
return {"success": True}
|
backend/app/scripts/seed.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import uuid
|
| 4 |
+
import random
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
|
| 7 |
+
# Add parent directory to path so we can import from app
|
| 8 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 9 |
+
|
| 10 |
+
from app.database.database import SessionLocal, engine, SQLALCHEMY_DATABASE_URL
|
| 11 |
+
from app.database.models import (
|
| 12 |
+
Base, User, Account, Transaction, Subscription,
|
| 13 |
+
Goal, Investment, AIInsight, FraudLog, Notification, AnalyticsSnapshot
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Create tables
|
| 17 |
+
Base.metadata.create_all(bind=engine)
|
| 18 |
+
|
| 19 |
+
def seed_data():
|
| 20 |
+
db = SessionLocal()
|
| 21 |
+
|
| 22 |
+
print(f"Seeding into: {SQLALCHEMY_DATABASE_URL}")
|
| 23 |
+
|
| 24 |
+
# Check if we already have users
|
| 25 |
+
if db.query(User).count() > 0:
|
| 26 |
+
print("Database already seeded.")
|
| 27 |
+
db.close()
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
print("Seeding database...")
|
| 31 |
+
|
| 32 |
+
personas = ["Saver", "Investor", "Impulsive Spender", "Minimalist", "Risk Taker"]
|
| 33 |
+
|
| 34 |
+
merchants = ["Swiggy", "Amazon", "Netflix", "Uber", "Fuel", "Salary", "SIP",
|
| 35 |
+
"Starbucks", "Apple", "Walmart"]
|
| 36 |
+
categories = ["Food", "Shopping", "Entertainment", "Transport", "Income",
|
| 37 |
+
"Investment", "Groceries", "Tech", "Utilities"]
|
| 38 |
+
|
| 39 |
+
for persona in personas:
|
| 40 |
+
try:
|
| 41 |
+
user = User(
|
| 42 |
+
email=f"{persona.lower().replace(' ', '_')}@example.com",
|
| 43 |
+
password_hash="hashed_password",
|
| 44 |
+
profile_data={"name": f"{persona} User", "phone": "+1234567890"},
|
| 45 |
+
financial_personality=persona,
|
| 46 |
+
ai_personalization_settings={"theme": "dark", "notifications": "all"}
|
| 47 |
+
)
|
| 48 |
+
db.add(user)
|
| 49 |
+
db.flush() # get user.id without committing
|
| 50 |
+
|
| 51 |
+
# ββ Accounts ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
checking = Account(
|
| 53 |
+
user_id=user.id, type="checking",
|
| 54 |
+
balance=random.uniform(1000.0, 10000.0), currency="USD"
|
| 55 |
+
)
|
| 56 |
+
savings = Account(
|
| 57 |
+
user_id=user.id, type="savings",
|
| 58 |
+
balance=random.uniform(5000.0, 50000.0), currency="USD"
|
| 59 |
+
)
|
| 60 |
+
db.add_all([checking, savings])
|
| 61 |
+
db.flush()
|
| 62 |
+
|
| 63 |
+
# ββ Subscriptions βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
# Active subscription (high usage)
|
| 65 |
+
db.add(Subscription(
|
| 66 |
+
user_id=user.id, merchant="Netflix", amount=15.99,
|
| 67 |
+
billing_cycle="monthly", active=True,
|
| 68 |
+
ai_usage_detection={"usage_frequency": "high", "recommendation": "keep"}
|
| 69 |
+
))
|
| 70 |
+
# Unused subscription β triggers unused detection in subscriptions.py
|
| 71 |
+
db.add(Subscription(
|
| 72 |
+
user_id=user.id, merchant="Spotify", amount=9.99,
|
| 73 |
+
billing_cycle="monthly", active=True,
|
| 74 |
+
ai_usage_detection={"usage_frequency": "low", "recommendation": "cancel"}
|
| 75 |
+
))
|
| 76 |
+
# Duplicate subscription β triggers duplicate detection in subscriptions.py
|
| 77 |
+
# (second Netflix entry for the same user)
|
| 78 |
+
db.add(Subscription(
|
| 79 |
+
user_id=user.id, merchant="Netflix", amount=15.99,
|
| 80 |
+
billing_cycle="monthly", active=True,
|
| 81 |
+
ai_usage_detection={"usage_frequency": "medium", "recommendation": "review"}
|
| 82 |
+
))
|
| 83 |
+
|
| 84 |
+
# ββ Goals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
db.add(Goal(
|
| 86 |
+
user_id=user.id, title="Emergency Fund",
|
| 87 |
+
target_amount=10000.0,
|
| 88 |
+
current_amount=random.uniform(1000.0, 5000.0),
|
| 89 |
+
target_date=datetime.utcnow() + timedelta(days=365),
|
| 90 |
+
ai_generated_plan={"monthly_saving_required": 500.0, "risk": "low"}
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
# ββ Investments βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
db.add(Investment(
|
| 95 |
+
user_id=user.id, asset_name="S&P 500", type="stock",
|
| 96 |
+
amount_invested=random.uniform(1000.0, 10000.0),
|
| 97 |
+
current_value=random.uniform(1100.0, 12000.0),
|
| 98 |
+
portfolio_allocation=50.0,
|
| 99 |
+
ai_risk_analysis={"risk_level": "medium", "recommendation": "hold"}
|
| 100 |
+
))
|
| 101 |
+
|
| 102 |
+
# ββ Transactions ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
start_date = datetime.utcnow() - timedelta(days=90)
|
| 104 |
+
|
| 105 |
+
# Monthly salary (3 months)
|
| 106 |
+
for i in range(3):
|
| 107 |
+
tx_date = start_date + timedelta(days=i * 30)
|
| 108 |
+
db.add(Transaction(
|
| 109 |
+
account_id=checking.id, amount=5000.0, type="credit",
|
| 110 |
+
category="Income", timestamp=tx_date, merchant="Salary",
|
| 111 |
+
tags=["salary", "income"],
|
| 112 |
+
ai_generated_metadata={"is_recurring": True, "confidence": 0.99},
|
| 113 |
+
spending_emotion_label="neutral"
|
| 114 |
+
))
|
| 115 |
+
|
| 116 |
+
# Regular expense transactions
|
| 117 |
+
for _ in range(30):
|
| 118 |
+
tx_date = start_date + timedelta(days=random.randint(0, 89))
|
| 119 |
+
amount = random.uniform(10.0, 500.0)
|
| 120 |
+
merchant = random.choice(merchants)
|
| 121 |
+
|
| 122 |
+
if merchant == "Salary":
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
# Persona-based spending adjustments
|
| 126 |
+
if user.financial_personality == "Saver" and amount > 200:
|
| 127 |
+
amount = random.uniform(10.0, 100.0)
|
| 128 |
+
elif user.financial_personality == "Impulsive Spender":
|
| 129 |
+
amount = random.uniform(50.0, 800.0)
|
| 130 |
+
|
| 131 |
+
tx = Transaction(
|
| 132 |
+
account_id=checking.id, amount=amount, type="debit",
|
| 133 |
+
category=random.choice(categories),
|
| 134 |
+
timestamp=tx_date, merchant=merchant,
|
| 135 |
+
tags=["expense"],
|
| 136 |
+
ai_generated_metadata={"category_confidence": 0.9},
|
| 137 |
+
spending_emotion_label=random.choice(["happy", "regret", "neutral", "essential"])
|
| 138 |
+
)
|
| 139 |
+
db.add(tx)
|
| 140 |
+
db.flush()
|
| 141 |
+
|
| 142 |
+
# Seed a fraud log for ~5% of transactions
|
| 143 |
+
if random.random() < 0.05:
|
| 144 |
+
db.add(FraudLog(
|
| 145 |
+
transaction_id=tx.id,
|
| 146 |
+
risk_score=random.uniform(0.7, 0.99),
|
| 147 |
+
suspicious_activity_details="Unusual location and high amount for this merchant.",
|
| 148 |
+
status="pending"
|
| 149 |
+
))
|
| 150 |
+
|
| 151 |
+
# Late-night transaction β ensures behavior.py late-night detection fires
|
| 152 |
+
late_night_date = start_date + timedelta(days=random.randint(1, 80),
|
| 153 |
+
hours=23, minutes=random.randint(0, 59))
|
| 154 |
+
db.add(Transaction(
|
| 155 |
+
account_id=checking.id,
|
| 156 |
+
amount=random.uniform(50.0, 300.0),
|
| 157 |
+
type="debit",
|
| 158 |
+
category="Entertainment",
|
| 159 |
+
timestamp=late_night_date,
|
| 160 |
+
merchant="Online Store",
|
| 161 |
+
tags=["late-night", "impulse"],
|
| 162 |
+
ai_generated_metadata={"category_confidence": 0.85},
|
| 163 |
+
spending_emotion_label="regret"
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
# ββ Supporting records ββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
db.add(AIInsight(
|
| 168 |
+
user_id=user.id, type="cashflow",
|
| 169 |
+
content=f"You are spending 20% more on {random.choice(categories)} this month."
|
| 170 |
+
))
|
| 171 |
+
db.add(Notification(
|
| 172 |
+
user_id=user.id, title="Weekly Summary",
|
| 173 |
+
message="Your weekly financial summary is ready.", type="insight"
|
| 174 |
+
))
|
| 175 |
+
db.add(AnalyticsSnapshot(
|
| 176 |
+
user_id=user.id, date=datetime.utcnow(),
|
| 177 |
+
total_balance=checking.balance + savings.balance,
|
| 178 |
+
total_spending=2000.0, total_savings=3000.0,
|
| 179 |
+
financial_score=random.uniform(60.0, 95.0),
|
| 180 |
+
trends_json={"spending_trend": "down", "savings_trend": "up"}
|
| 181 |
+
))
|
| 182 |
+
|
| 183 |
+
db.commit()
|
| 184 |
+
print(f" β Seeded user: {persona}")
|
| 185 |
+
|
| 186 |
+
except Exception as e:
|
| 187 |
+
db.rollback()
|
| 188 |
+
print(f" β Failed to seed user '{persona}': {e}")
|
| 189 |
+
|
| 190 |
+
db.close()
|
| 191 |
+
print("Database seeded successfully!")
|
| 192 |
+
|
| 193 |
+
if __name__ == "__main__":
|
| 194 |
+
seed_data()
|
backend/app/scripts/seed_demo.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Demo seed script β creates a polished demo account (alex@bankbot.dev)
|
| 3 |
+
with realistic financial data: transactions, goals, investments,
|
| 4 |
+
subscriptions, notifications, and a fraud alert.
|
| 5 |
+
|
| 6 |
+
Run: python app/scripts/seed_demo.py
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import uuid
|
| 11 |
+
import random
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 15 |
+
|
| 16 |
+
import bcrypt as _bcrypt
|
| 17 |
+
from app.database.database import SessionLocal, engine
|
| 18 |
+
from app.database.models import (
|
| 19 |
+
Base, User, Account, Transaction, Subscription,
|
| 20 |
+
Goal, Investment, Notification, FraudLog
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
Base.metadata.create_all(bind=engine)
|
| 24 |
+
|
| 25 |
+
DEMO_EMAIL = "alex@bankbot.dev"
|
| 26 |
+
DEMO_PASSWORD = "BankBot2026!"
|
| 27 |
+
DEMO_NAME = "Alex Doe"
|
| 28 |
+
|
| 29 |
+
def hash_pw(pw: str) -> str:
|
| 30 |
+
return _bcrypt.hashpw(pw.encode(), _bcrypt.gensalt(rounds=12)).decode()
|
| 31 |
+
|
| 32 |
+
def uid() -> str:
|
| 33 |
+
return str(uuid.uuid4())
|
| 34 |
+
|
| 35 |
+
def seed():
|
| 36 |
+
db = SessionLocal()
|
| 37 |
+
try:
|
| 38 |
+
# ββ Remove existing demo user ββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
existing = db.query(User).filter(User.email == DEMO_EMAIL).first()
|
| 40 |
+
if existing:
|
| 41 |
+
db.delete(existing)
|
| 42 |
+
db.commit()
|
| 43 |
+
print(f"Removed existing demo user: {DEMO_EMAIL}")
|
| 44 |
+
|
| 45 |
+
# ββ Create demo user βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
user = User(
|
| 47 |
+
id=uid(),
|
| 48 |
+
email=DEMO_EMAIL,
|
| 49 |
+
password_hash=hash_pw(DEMO_PASSWORD),
|
| 50 |
+
profile_data={
|
| 51 |
+
"name": DEMO_NAME,
|
| 52 |
+
"phone": "+1 (555) 012-3456",
|
| 53 |
+
"avatar": "AD",
|
| 54 |
+
"member_since": "2023-01-15",
|
| 55 |
+
"plan": "Premium",
|
| 56 |
+
},
|
| 57 |
+
financial_personality="Balanced Investor",
|
| 58 |
+
ai_personalization_settings={
|
| 59 |
+
"risk_tolerance": "moderate",
|
| 60 |
+
"investment_horizon": "long_term",
|
| 61 |
+
"notifications": "all",
|
| 62 |
+
"ai_tone": "analytical",
|
| 63 |
+
},
|
| 64 |
+
)
|
| 65 |
+
db.add(user)
|
| 66 |
+
db.flush()
|
| 67 |
+
print(f"Created demo user: {DEMO_EMAIL}")
|
| 68 |
+
|
| 69 |
+
# ββ Accounts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
checking = Account(id=uid(), user_id=user.id, type="checking",
|
| 71 |
+
balance=12_847.32, currency="USD", status="active")
|
| 72 |
+
savings = Account(id=uid(), user_id=user.id, type="savings",
|
| 73 |
+
balance=28_450.00, currency="USD", status="active")
|
| 74 |
+
invest = Account(id=uid(), user_id=user.id, type="investment",
|
| 75 |
+
balance=18_340.50, currency="USD", status="active")
|
| 76 |
+
db.add_all([checking, savings, invest])
|
| 77 |
+
db.flush()
|
| 78 |
+
print("Created 3 accounts (checking $12,847 | savings $28,450 | investment $18,340)")
|
| 79 |
+
|
| 80 |
+
# ββ Transactions β 6 months of realistic data βββββββββββββββββββββββββ
|
| 81 |
+
now = datetime.utcnow()
|
| 82 |
+
merchants = [
|
| 83 |
+
# (name, category, type, amount_range)
|
| 84 |
+
("Salary Deposit", "Income", "credit", (4800, 5200)),
|
| 85 |
+
("Freelance Payment", "Income", "credit", (800, 2000)),
|
| 86 |
+
("Whole Foods", "Groceries", "debit", (45, 180)),
|
| 87 |
+
("Trader Joe's", "Groceries", "debit", (30, 120)),
|
| 88 |
+
("Netflix", "Entertainment", "debit", (15, 16)),
|
| 89 |
+
("Spotify", "Entertainment", "debit", (9, 10)),
|
| 90 |
+
("Amazon", "Shopping", "debit", (25, 250)),
|
| 91 |
+
("Apple Store", "Tech", "debit", (10, 200)),
|
| 92 |
+
("Uber", "Transport", "debit", (8, 45)),
|
| 93 |
+
("Shell Gas", "Transport", "debit", (40, 80)),
|
| 94 |
+
("Starbucks", "Food", "debit", (5, 18)),
|
| 95 |
+
("Chipotle", "Food", "debit", (10, 25)),
|
| 96 |
+
("Planet Fitness", "Health", "debit", (24, 25)),
|
| 97 |
+
("CVS Pharmacy", "Health", "debit", (12, 60)),
|
| 98 |
+
("Con Edison", "Utilities", "debit", (80, 140)),
|
| 99 |
+
("Verizon", "Utilities", "debit", (85, 90)),
|
| 100 |
+
("Rent Payment", "Housing", "debit", (1950, 1950)),
|
| 101 |
+
("Dividend Income", "Investment", "credit", (120, 350)),
|
| 102 |
+
("Restaurant", "Food", "debit", (30, 90)),
|
| 103 |
+
("Target", "Shopping", "debit", (40, 150)),
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
txns = []
|
| 107 |
+
for month_offset in range(6):
|
| 108 |
+
month_start = (now.replace(day=1) - timedelta(days=month_offset * 30))
|
| 109 |
+
# Salary on 1st
|
| 110 |
+
txns.append(Transaction(
|
| 111 |
+
id=uid(), account_id=checking.id,
|
| 112 |
+
amount=random.uniform(4800, 5200), type="credit",
|
| 113 |
+
category="Income", merchant="Salary Deposit",
|
| 114 |
+
timestamp=month_start + timedelta(hours=9),
|
| 115 |
+
tags=["recurring", "income"],
|
| 116 |
+
))
|
| 117 |
+
# Rent on 3rd
|
| 118 |
+
txns.append(Transaction(
|
| 119 |
+
id=uid(), account_id=checking.id,
|
| 120 |
+
amount=1950.00, type="debit",
|
| 121 |
+
category="Housing", merchant="Rent Payment",
|
| 122 |
+
timestamp=month_start + timedelta(days=2, hours=10),
|
| 123 |
+
tags=["recurring", "housing"],
|
| 124 |
+
))
|
| 125 |
+
# Random daily transactions
|
| 126 |
+
for _ in range(random.randint(18, 28)):
|
| 127 |
+
m = random.choice(merchants[2:]) # skip salary/rent
|
| 128 |
+
days_offset = random.randint(0, 28)
|
| 129 |
+
hours_offset = random.randint(7, 22)
|
| 130 |
+
txns.append(Transaction(
|
| 131 |
+
id=uid(), account_id=checking.id,
|
| 132 |
+
amount=round(random.uniform(*m[3]), 2),
|
| 133 |
+
type=m[2], category=m[1], merchant=m[0],
|
| 134 |
+
timestamp=month_start + timedelta(days=days_offset, hours=hours_offset),
|
| 135 |
+
tags=[m[1].lower()],
|
| 136 |
+
))
|
| 137 |
+
|
| 138 |
+
# One suspicious transaction for fraud demo
|
| 139 |
+
fraud_txn = Transaction(
|
| 140 |
+
id=uid(), account_id=checking.id,
|
| 141 |
+
amount=847.00, type="debit",
|
| 142 |
+
category="Shopping", merchant="Tech Store NYC",
|
| 143 |
+
timestamp=now - timedelta(hours=2),
|
| 144 |
+
tags=["flagged"],
|
| 145 |
+
spending_emotion_label="impulsive",
|
| 146 |
+
)
|
| 147 |
+
txns.append(fraud_txn)
|
| 148 |
+
db.add_all(txns)
|
| 149 |
+
db.flush()
|
| 150 |
+
print(f"Created {len(txns)} transactions across 6 months")
|
| 151 |
+
|
| 152 |
+
# ββ Fraud log for the suspicious transaction ββββββββββββββββββββββββββ
|
| 153 |
+
fraud_log = FraudLog(
|
| 154 |
+
id=uid(),
|
| 155 |
+
transaction_id=fraud_txn.id,
|
| 156 |
+
risk_score=0.78,
|
| 157 |
+
suspicious_activity_details=(
|
| 158 |
+
"Transaction amount ($847.00) is 3.2x above historical average. "
|
| 159 |
+
"Location anomaly: merchant in NYC, usual activity in Brooklyn. "
|
| 160 |
+
"Placed at 11:47 PM β outside normal spending hours."
|
| 161 |
+
),
|
| 162 |
+
status="pending",
|
| 163 |
+
)
|
| 164 |
+
db.add(fraud_log)
|
| 165 |
+
print("Created fraud alert for demo transaction")
|
| 166 |
+
|
| 167 |
+
# ββ Goals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
goals = [
|
| 169 |
+
Goal(id=uid(), user_id=user.id, title="Emergency Fund",
|
| 170 |
+
target_amount=18_000, current_amount=14_200,
|
| 171 |
+
target_date=now + timedelta(days=90),
|
| 172 |
+
ai_generated_plan={"monthly_contribution": 1267, "months_remaining": 3}),
|
| 173 |
+
Goal(id=uid(), user_id=user.id, title="Europe Vacation",
|
| 174 |
+
target_amount=5_000, current_amount=2_800,
|
| 175 |
+
target_date=now + timedelta(days=180),
|
| 176 |
+
ai_generated_plan={"monthly_contribution": 367, "months_remaining": 6}),
|
| 177 |
+
Goal(id=uid(), user_id=user.id, title="MacBook Pro",
|
| 178 |
+
target_amount=2_500, current_amount=1_900,
|
| 179 |
+
target_date=now + timedelta(days=45),
|
| 180 |
+
ai_generated_plan={"monthly_contribution": 300, "months_remaining": 2}),
|
| 181 |
+
Goal(id=uid(), user_id=user.id, title="Down Payment Fund",
|
| 182 |
+
target_amount=80_000, current_amount=28_450,
|
| 183 |
+
target_date=now + timedelta(days=730),
|
| 184 |
+
ai_generated_plan={"monthly_contribution": 2148, "months_remaining": 24}),
|
| 185 |
+
]
|
| 186 |
+
db.add_all(goals)
|
| 187 |
+
print(f"Created {len(goals)} financial goals")
|
| 188 |
+
|
| 189 |
+
# ββ Investments βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
investments = [
|
| 191 |
+
Investment(id=uid(), user_id=user.id, asset_name="S&P 500 Index Fund",
|
| 192 |
+
type="mutual_fund", amount_invested=8_000, current_value=9_840,
|
| 193 |
+
portfolio_allocation=53.6,
|
| 194 |
+
ai_risk_analysis={"risk": "moderate", "expected_return": "8-10%", "recommendation": "hold"}),
|
| 195 |
+
Investment(id=uid(), user_id=user.id, asset_name="Apple Inc (AAPL)",
|
| 196 |
+
type="stock", amount_invested=3_000, current_value=3_720,
|
| 197 |
+
portfolio_allocation=20.3,
|
| 198 |
+
ai_risk_analysis={"risk": "moderate-high", "expected_return": "12-15%", "recommendation": "hold"}),
|
| 199 |
+
Investment(id=uid(), user_id=user.id, asset_name="Bitcoin (BTC)",
|
| 200 |
+
type="crypto", amount_invested=2_500, current_value=2_980,
|
| 201 |
+
portfolio_allocation=16.2,
|
| 202 |
+
ai_risk_analysis={"risk": "high", "expected_return": "variable", "recommendation": "reduce_exposure"}),
|
| 203 |
+
Investment(id=uid(), user_id=user.id, asset_name="US Treasury Bonds",
|
| 204 |
+
type="bond", amount_invested=1_800, current_value=1_800,
|
| 205 |
+
portfolio_allocation=9.8,
|
| 206 |
+
ai_risk_analysis={"risk": "low", "expected_return": "4.5%", "recommendation": "hold"}),
|
| 207 |
+
]
|
| 208 |
+
db.add_all(investments)
|
| 209 |
+
print(f"Created {len(investments)} investments (total value: ${sum(i.current_value for i in investments):,.0f})")
|
| 210 |
+
|
| 211 |
+
# ββ Subscriptions βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
subscriptions = [
|
| 213 |
+
Subscription(id=uid(), user_id=user.id, merchant="Netflix",
|
| 214 |
+
amount=15.99, billing_cycle="monthly", active=True,
|
| 215 |
+
ai_usage_detection={"last_used": "2 days ago", "usage_frequency": "high"}),
|
| 216 |
+
Subscription(id=uid(), user_id=user.id, merchant="Spotify",
|
| 217 |
+
amount=9.99, billing_cycle="monthly", active=True,
|
| 218 |
+
ai_usage_detection={"last_used": "today", "usage_frequency": "daily"}),
|
| 219 |
+
Subscription(id=uid(), user_id=user.id, merchant="Adobe Creative Cloud",
|
| 220 |
+
amount=54.99, billing_cycle="monthly", active=True,
|
| 221 |
+
ai_usage_detection={"last_used": "45 days ago", "usage_frequency": "low"}),
|
| 222 |
+
Subscription(id=uid(), user_id=user.id, merchant="Planet Fitness",
|
| 223 |
+
amount=24.99, billing_cycle="monthly", active=True,
|
| 224 |
+
ai_usage_detection={"last_used": "1 week ago", "usage_frequency": "medium"}),
|
| 225 |
+
Subscription(id=uid(), user_id=user.id, merchant="iCloud Storage",
|
| 226 |
+
amount=2.99, billing_cycle="monthly", active=True,
|
| 227 |
+
ai_usage_detection={"last_used": "today", "usage_frequency": "daily"}),
|
| 228 |
+
Subscription(id=uid(), user_id=user.id, merchant="LinkedIn Premium",
|
| 229 |
+
amount=39.99, billing_cycle="monthly", active=True,
|
| 230 |
+
ai_usage_detection={"last_used": "60 days ago", "usage_frequency": "very_low"}),
|
| 231 |
+
]
|
| 232 |
+
db.add_all(subscriptions)
|
| 233 |
+
monthly_sub_cost = sum(s.amount for s in subscriptions)
|
| 234 |
+
print(f"Created {len(subscriptions)} subscriptions (${monthly_sub_cost:.2f}/month)")
|
| 235 |
+
|
| 236 |
+
# ββ Notifications βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
+
notifications = [
|
| 238 |
+
Notification(id=uid(), user_id=user.id,
|
| 239 |
+
title="π¨ Unusual Transaction Detected",
|
| 240 |
+
message="A charge of $847.00 at 'Tech Store NYC' was flagged. "
|
| 241 |
+
"This is 3.2x your average transaction and occurred at 11:47 PM. "
|
| 242 |
+
"Please review and confirm.",
|
| 243 |
+
type="alert", read_status=False,
|
| 244 |
+
created_at=now - timedelta(hours=2)),
|
| 245 |
+
Notification(id=uid(), user_id=user.id,
|
| 246 |
+
title="π‘ AI Weekly Insight",
|
| 247 |
+
message="Your savings rate this month is 38.4% β 18% above the national average. "
|
| 248 |
+
"At this pace, you'll reach your Emergency Fund goal in 3 months.",
|
| 249 |
+
type="insight", read_status=False,
|
| 250 |
+
created_at=now - timedelta(hours=6)),
|
| 251 |
+
Notification(id=uid(), user_id=user.id,
|
| 252 |
+
title="β οΈ Budget Alert: Shopping",
|
| 253 |
+
message="You've spent $847 in Shopping this month β 141% of your $600 budget. "
|
| 254 |
+
"Consider pausing non-essential purchases for the rest of the month.",
|
| 255 |
+
type="warning", read_status=False,
|
| 256 |
+
created_at=now - timedelta(hours=8)),
|
| 257 |
+
Notification(id=uid(), user_id=user.id,
|
| 258 |
+
title="π― Goal Milestone Reached",
|
| 259 |
+
message="Your Emergency Fund is now 78.9% funded ($14,200 of $18,000). "
|
| 260 |
+
"You're on track to complete it by August 2026.",
|
| 261 |
+
type="insight", read_status=True,
|
| 262 |
+
created_at=now - timedelta(days=1)),
|
| 263 |
+
Notification(id=uid(), user_id=user.id,
|
| 264 |
+
title="π Monthly Report Ready",
|
| 265 |
+
message="Your May 2026 financial report is ready. "
|
| 266 |
+
"Net savings: $1,847. Top category: Housing (38%). "
|
| 267 |
+
"Health score improved by 3 points to 82/100.",
|
| 268 |
+
type="insight", read_status=True,
|
| 269 |
+
created_at=now - timedelta(days=2)),
|
| 270 |
+
Notification(id=uid(), user_id=user.id,
|
| 271 |
+
title="π° Subscription Optimization",
|
| 272 |
+
message="AI detected 2 underused subscriptions: Adobe CC ($54.99/mo, last used 45 days ago) "
|
| 273 |
+
"and LinkedIn Premium ($39.99/mo, last used 60 days ago). "
|
| 274 |
+
"Cancelling both saves $1,139.76/year.",
|
| 275 |
+
type="warning", read_status=True,
|
| 276 |
+
created_at=now - timedelta(days=3)),
|
| 277 |
+
]
|
| 278 |
+
db.add_all(notifications)
|
| 279 |
+
print(f"Created {len(notifications)} notifications ({sum(1 for n in notifications if not n.read_status)} unread)")
|
| 280 |
+
|
| 281 |
+
db.commit()
|
| 282 |
+
print("\n" + "="*60)
|
| 283 |
+
print("DEMO ACCOUNT SEEDED SUCCESSFULLY")
|
| 284 |
+
print("="*60)
|
| 285 |
+
print(f" Email: {DEMO_EMAIL}")
|
| 286 |
+
print(f" Password: {DEMO_PASSWORD}")
|
| 287 |
+
print(f" Balance: ${checking.balance + savings.balance + invest.balance:,.2f} total")
|
| 288 |
+
print(f" Score: 82/100 (estimated)")
|
| 289 |
+
print(f" Fraud: 1 pending alert")
|
| 290 |
+
print("="*60)
|
| 291 |
+
|
| 292 |
+
except Exception as e:
|
| 293 |
+
db.rollback()
|
| 294 |
+
print(f"SEED FAILED: {e}")
|
| 295 |
+
raise
|
| 296 |
+
finally:
|
| 297 |
+
db.close()
|
| 298 |
+
|
| 299 |
+
if __name__ == "__main__":
|
| 300 |
+
seed()
|
backend/app/scripts/test_endpoints.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BankBot AI Endpoint Validation Script
|
| 3 |
+
======================================
|
| 4 |
+
Calls every AI endpoint and asserts the response shape is correct.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
# From the backend/ directory with the server running:
|
| 8 |
+
python app/scripts/test_endpoints.py
|
| 9 |
+
|
| 10 |
+
Exit codes:
|
| 11 |
+
0 β all tests passed
|
| 12 |
+
1 β one or more tests failed
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import json
|
| 17 |
+
import httpx
|
| 18 |
+
|
| 19 |
+
BASE_URL = "http://127.0.0.1:8000"
|
| 20 |
+
|
| 21 |
+
# βββ Result tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
|
| 23 |
+
results = [] # list of (name, passed, detail)
|
| 24 |
+
|
| 25 |
+
def record(name: str, passed: bool, detail: str = ""):
|
| 26 |
+
results.append((name, passed, detail))
|
| 27 |
+
|
| 28 |
+
# βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
def get(path: str, params: dict = None):
|
| 31 |
+
return httpx.get(f"{BASE_URL}{path}", params=params, timeout=60)
|
| 32 |
+
|
| 33 |
+
def post(path: str, body: dict):
|
| 34 |
+
return httpx.post(f"{BASE_URL}{path}", json=body, timeout=60)
|
| 35 |
+
|
| 36 |
+
def assert_keys(data: dict, *keys):
|
| 37 |
+
missing = [k for k in keys if k not in data]
|
| 38 |
+
if missing:
|
| 39 |
+
raise AssertionError(f"Missing keys: {missing}")
|
| 40 |
+
|
| 41 |
+
# βββ Tests ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
|
| 43 |
+
def test_health():
|
| 44 |
+
r = get("/health")
|
| 45 |
+
assert r.status_code == 200
|
| 46 |
+
assert r.json().get("status") == "healthy"
|
| 47 |
+
record("GET /health", True)
|
| 48 |
+
|
| 49 |
+
def test_ai_status():
|
| 50 |
+
r = get("/api/ai/status")
|
| 51 |
+
assert r.status_code == 200
|
| 52 |
+
data = r.json()
|
| 53 |
+
assert_keys(data, "ai_backend", "ai_available", "db_type", "cache_type")
|
| 54 |
+
assert data["db_type"] in ("sqlite", "postgresql")
|
| 55 |
+
assert data["cache_type"] in ("redis", "memory")
|
| 56 |
+
record("GET /api/ai/status", True,
|
| 57 |
+
f"backend={data['ai_backend']} db={data['db_type']} cache={data['cache_type']}")
|
| 58 |
+
|
| 59 |
+
def test_twin_predict():
|
| 60 |
+
r = get("/api/ai/twin/predict")
|
| 61 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 62 |
+
data = r.json()
|
| 63 |
+
assert_keys(data, "current_balance", "projected_balance", "percent_change",
|
| 64 |
+
"net_daily", "insight", "chart_data")
|
| 65 |
+
assert isinstance(data["chart_data"], list) and len(data["chart_data"]) >= 1
|
| 66 |
+
assert data["projected_balance"] >= 0.0, "projected_balance must be non-negative"
|
| 67 |
+
record("GET /api/ai/twin/predict", True,
|
| 68 |
+
f"balance=${data['current_balance']:,.2f} β ${data['projected_balance']:,.2f}")
|
| 69 |
+
|
| 70 |
+
def test_twin_future():
|
| 71 |
+
r = get("/api/ai/twin/future", params={"months": 12})
|
| 72 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 73 |
+
data = r.json()
|
| 74 |
+
assert_keys(data, "savings_growth", "investment_growth", "debt_decline")
|
| 75 |
+
assert len(data["savings_growth"]) >= 1
|
| 76 |
+
assert len(data["investment_growth"]) >= 1
|
| 77 |
+
record("GET /api/ai/twin/future", True,
|
| 78 |
+
f"savings_points={len(data['savings_growth'])}")
|
| 79 |
+
|
| 80 |
+
def test_twin_scenarios():
|
| 81 |
+
r = get("/api/ai/twin/scenarios", params={"months": 6})
|
| 82 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 83 |
+
data = r.json()
|
| 84 |
+
assert_keys(data, "status_quo", "frugal", "lifestyle_inflation")
|
| 85 |
+
for key in ("status_quo", "frugal", "lifestyle_inflation"):
|
| 86 |
+
assert "balance_projection" in data[key], f"Missing balance_projection in {key}"
|
| 87 |
+
record("GET /api/ai/twin/scenarios", True)
|
| 88 |
+
|
| 89 |
+
def test_simulate_purchase():
|
| 90 |
+
r = post("/api/ai/simulate/purchase", {
|
| 91 |
+
"amount": 500.0, "merchant": "Test Store", "category": "Shopping"
|
| 92 |
+
})
|
| 93 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 94 |
+
data = r.json()
|
| 95 |
+
assert_keys(data, "risk_analysis", "projected_balance", "recommendation")
|
| 96 |
+
assert data["risk_analysis"]["risk_level"] in ("low", "medium", "high", "critical")
|
| 97 |
+
assert data["projected_balance"] >= 0.0
|
| 98 |
+
record("POST /api/ai/simulate/purchase", True,
|
| 99 |
+
f"risk={data['risk_analysis']['risk_level']}")
|
| 100 |
+
|
| 101 |
+
def test_simulate_investment():
|
| 102 |
+
r = post("/api/ai/simulate/investment", {
|
| 103 |
+
"monthly_sip": 200.0, "asset_type": "stock", "lump_sum": 0.0
|
| 104 |
+
})
|
| 105 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 106 |
+
data = r.json()
|
| 107 |
+
assert_keys(data, "growth_projection", "is_affordable", "risk_analysis")
|
| 108 |
+
assert len(data["growth_projection"]) == 3, \
|
| 109 |
+
f"Expected 3 growth milestones (1/3/5 yr), got {len(data['growth_projection'])}"
|
| 110 |
+
record("POST /api/ai/simulate/investment", True,
|
| 111 |
+
f"affordable={data['is_affordable']}")
|
| 112 |
+
|
| 113 |
+
def test_simulate_subscription():
|
| 114 |
+
# First fetch a real subscription ID from the optimize endpoint
|
| 115 |
+
r_subs = get("/api/ai/subscriptions/optimize")
|
| 116 |
+
assert r_subs.status_code == 200
|
| 117 |
+
subs = r_subs.json().get("subscriptions", [])
|
| 118 |
+
if not subs:
|
| 119 |
+
record("POST /api/ai/simulate/subscription", True, "skipped β no subscriptions in DB")
|
| 120 |
+
return
|
| 121 |
+
sub_id = subs[0]["id"]
|
| 122 |
+
r = post("/api/ai/simulate/subscription", {"subscription_ids": [sub_id]})
|
| 123 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 124 |
+
data = r.json()
|
| 125 |
+
assert_keys(data, "monthly_savings", "yearly_savings", "recommendation")
|
| 126 |
+
assert data["monthly_savings"] >= 0.0
|
| 127 |
+
record("POST /api/ai/simulate/subscription", True,
|
| 128 |
+
f"monthly_savings=${data['monthly_savings']:.2f}")
|
| 129 |
+
|
| 130 |
+
def test_behavior_insights():
|
| 131 |
+
r = get("/api/ai/behavior/insights")
|
| 132 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 133 |
+
data = r.json()
|
| 134 |
+
assert_keys(data, "insights", "metrics")
|
| 135 |
+
assert isinstance(data["insights"], list) and len(data["insights"]) >= 1, \
|
| 136 |
+
"insights must be a non-empty list"
|
| 137 |
+
record("GET /api/ai/behavior/insights", True,
|
| 138 |
+
f"insights={len(data['insights'])}")
|
| 139 |
+
|
| 140 |
+
def test_coaching_score():
|
| 141 |
+
r = get("/api/ai/coaching/score")
|
| 142 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 143 |
+
data = r.json()
|
| 144 |
+
assert_keys(data, "overall_score", "categories", "explanation", "actionable_improvements")
|
| 145 |
+
score = data["overall_score"]
|
| 146 |
+
assert 0 <= score <= 100, f"overall_score {score} out of [0, 100]"
|
| 147 |
+
expected_cats = ("savings_consistency", "debt_ratio", "spending_discipline",
|
| 148 |
+
"emergency_funds", "investments", "subscription_management")
|
| 149 |
+
for cat in expected_cats:
|
| 150 |
+
assert cat in data["categories"], f"Missing category: {cat}"
|
| 151 |
+
assert len(data["actionable_improvements"]) >= 1
|
| 152 |
+
record("GET /api/ai/coaching/score", True, f"score={score}/100")
|
| 153 |
+
|
| 154 |
+
def test_coaching_briefing():
|
| 155 |
+
# This endpoint calls an LLM β allow up to 120s for local Ollama inference
|
| 156 |
+
r = httpx.get(f"{BASE_URL}/api/ai/coaching/briefing", timeout=120)
|
| 157 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 158 |
+
data = r.json()
|
| 159 |
+
assert_keys(data, "date", "user_name", "briefing", "metrics")
|
| 160 |
+
assert isinstance(data["briefing"], str) and len(data["briefing"]) > 10
|
| 161 |
+
record("GET /api/ai/coaching/briefing", True,
|
| 162 |
+
f"briefing_len={len(data['briefing'])} chars")
|
| 163 |
+
|
| 164 |
+
def test_subscriptions_optimize():
|
| 165 |
+
r = get("/api/ai/subscriptions/optimize")
|
| 166 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 167 |
+
data = r.json()
|
| 168 |
+
assert_keys(data, "subscriptions", "duplicates", "unused_subscriptions",
|
| 169 |
+
"yearly_savings_potential", "risk_analysis")
|
| 170 |
+
record("GET /api/ai/subscriptions/optimize", True,
|
| 171 |
+
f"subs={len(data['subscriptions'])} "
|
| 172 |
+
f"dupes={len(data['duplicates'])} "
|
| 173 |
+
f"unused={len(data['unused_subscriptions'])}")
|
| 174 |
+
|
| 175 |
+
def test_fraud_analysis():
|
| 176 |
+
r = get("/api/ai/fraud/analysis")
|
| 177 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 178 |
+
data = r.json()
|
| 179 |
+
assert_keys(data, "total_alerts", "pending_reviews", "alerts")
|
| 180 |
+
assert isinstance(data["total_alerts"], int)
|
| 181 |
+
record("GET /api/ai/fraud/analysis", True,
|
| 182 |
+
f"alerts={data['total_alerts']}")
|
| 183 |
+
|
| 184 |
+
def test_chat():
|
| 185 |
+
# This endpoint calls an LLM β allow up to 120s for local Ollama inference
|
| 186 |
+
r = httpx.post(f"{BASE_URL}/api/ai/chat",
|
| 187 |
+
json={"message": "What is my current savings rate?"},
|
| 188 |
+
timeout=120)
|
| 189 |
+
assert r.status_code == 200, f"HTTP {r.status_code}: {r.text}"
|
| 190 |
+
data = r.json()
|
| 191 |
+
assert "response" in data, "Missing 'response' key"
|
| 192 |
+
assert isinstance(data["response"], str) and len(data["response"]) > 5
|
| 193 |
+
record("POST /api/ai/chat", True,
|
| 194 |
+
f"response_len={len(data['response'])} chars")
|
| 195 |
+
|
| 196 |
+
# βββ Runner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
+
|
| 198 |
+
TESTS = [
|
| 199 |
+
test_health,
|
| 200 |
+
test_ai_status,
|
| 201 |
+
test_twin_predict,
|
| 202 |
+
test_twin_future,
|
| 203 |
+
test_twin_scenarios,
|
| 204 |
+
test_simulate_purchase,
|
| 205 |
+
test_simulate_investment,
|
| 206 |
+
test_simulate_subscription,
|
| 207 |
+
test_behavior_insights,
|
| 208 |
+
test_coaching_score,
|
| 209 |
+
test_coaching_briefing,
|
| 210 |
+
test_subscriptions_optimize,
|
| 211 |
+
test_fraud_analysis,
|
| 212 |
+
test_chat,
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
print(f"\n{'β'*60}")
|
| 217 |
+
print(f" BankBot AI Endpoint Validation β {BASE_URL}")
|
| 218 |
+
print(f"{'β'*60}\n")
|
| 219 |
+
|
| 220 |
+
for test_fn in TESTS:
|
| 221 |
+
name = test_fn.__name__.replace("test_", "").replace("_", " ")
|
| 222 |
+
try:
|
| 223 |
+
test_fn()
|
| 224 |
+
# result already recorded inside test_fn on success
|
| 225 |
+
except AssertionError as e:
|
| 226 |
+
record(name, False, str(e))
|
| 227 |
+
except Exception as e:
|
| 228 |
+
record(name, False, f"Exception: {e}")
|
| 229 |
+
|
| 230 |
+
# ββ Summary table βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
print(f"\n{'β'*60}")
|
| 232 |
+
print(f" {'TEST':<40} {'RESULT':<8} DETAIL")
|
| 233 |
+
print(f"{'β'*60}")
|
| 234 |
+
|
| 235 |
+
passed = 0
|
| 236 |
+
failed = 0
|
| 237 |
+
for test_name, ok, detail in results:
|
| 238 |
+
status = "β
PASS" if ok else "β FAIL"
|
| 239 |
+
print(f" {test_name:<40} {status:<8} {detail}")
|
| 240 |
+
if ok:
|
| 241 |
+
passed += 1
|
| 242 |
+
else:
|
| 243 |
+
failed += 1
|
| 244 |
+
|
| 245 |
+
print(f"{'β'*60}")
|
| 246 |
+
print(f" {passed} passed | {failed} failed | {len(results)} total")
|
| 247 |
+
print(f"{'β'*60}\n")
|
| 248 |
+
|
| 249 |
+
sys.exit(0 if failed == 0 else 1)
|
backend/app/scripts/test_websocket.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BankBot WebSocket Streaming Validation Script
|
| 3 |
+
==============================================
|
| 4 |
+
Tests the /api/ai/chat/ws WebSocket endpoint for:
|
| 5 |
+
1. Streaming chat response (chat_start β chat_chunk(s) β chat_end)
|
| 6 |
+
2. Ping/pong keepalive
|
| 7 |
+
3. Invalid JSON error handling
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
# From the backend/ directory with the server running:
|
| 11 |
+
python app/scripts/test_websocket.py
|
| 12 |
+
|
| 13 |
+
Exit codes:
|
| 14 |
+
0 β all tests passed
|
| 15 |
+
1 β one or more tests failed
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import sys
|
| 19 |
+
import json
|
| 20 |
+
import asyncio
|
| 21 |
+
import websockets
|
| 22 |
+
|
| 23 |
+
WS_URL = "ws://127.0.0.1:8000/api/ai/chat/ws"
|
| 24 |
+
|
| 25 |
+
# βββ Result tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
results = [] # list of (name, passed, detail)
|
| 28 |
+
|
| 29 |
+
def record(name: str, passed: bool, detail: str = ""):
|
| 30 |
+
results.append((name, passed, detail))
|
| 31 |
+
|
| 32 |
+
# βββ Tests ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
async def test_chat_streaming():
|
| 35 |
+
"""
|
| 36 |
+
Sends a chat message and verifies the full streaming protocol:
|
| 37 |
+
chat_start β one or more chat_chunk β chat_end
|
| 38 |
+
"""
|
| 39 |
+
async with websockets.connect(WS_URL, open_timeout=10) as ws:
|
| 40 |
+
await ws.send(json.dumps({
|
| 41 |
+
"type": "chat",
|
| 42 |
+
"message": "What is my current balance and savings rate?"
|
| 43 |
+
}))
|
| 44 |
+
|
| 45 |
+
got_start = False
|
| 46 |
+
got_chunk = False
|
| 47 |
+
got_end = False
|
| 48 |
+
full_reply = ""
|
| 49 |
+
|
| 50 |
+
# Collect messages with a 30-second timeout
|
| 51 |
+
deadline = asyncio.get_event_loop().time() + 30
|
| 52 |
+
while asyncio.get_event_loop().time() < deadline:
|
| 53 |
+
try:
|
| 54 |
+
raw = await asyncio.wait_for(ws.recv(), timeout=30)
|
| 55 |
+
except asyncio.TimeoutError:
|
| 56 |
+
break
|
| 57 |
+
|
| 58 |
+
msg = json.loads(raw)
|
| 59 |
+
t = msg.get("type")
|
| 60 |
+
|
| 61 |
+
if t == "chat_start":
|
| 62 |
+
got_start = True
|
| 63 |
+
elif t == "chat_chunk":
|
| 64 |
+
got_chunk = True
|
| 65 |
+
full_reply += msg.get("content", "")
|
| 66 |
+
elif t == "chat_end":
|
| 67 |
+
got_end = True
|
| 68 |
+
break
|
| 69 |
+
elif t == "error":
|
| 70 |
+
raise AssertionError(f"Server returned error: {msg.get('message')}")
|
| 71 |
+
|
| 72 |
+
assert got_start, "Never received chat_start"
|
| 73 |
+
assert got_chunk, "Never received any chat_chunk"
|
| 74 |
+
assert got_end, "Never received chat_end"
|
| 75 |
+
assert len(full_reply) > 5, f"Assembled reply is too short: '{full_reply}'"
|
| 76 |
+
|
| 77 |
+
record("WS chat streaming", True,
|
| 78 |
+
f"reply_len={len(full_reply)} chars | preview: {full_reply[:80].strip()}...")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def test_ping_pong():
|
| 82 |
+
"""
|
| 83 |
+
Sends a ping and verifies the server responds with pong.
|
| 84 |
+
"""
|
| 85 |
+
async with websockets.connect(WS_URL, open_timeout=10) as ws:
|
| 86 |
+
await ws.send(json.dumps({"type": "ping"}))
|
| 87 |
+
|
| 88 |
+
raw = await asyncio.wait_for(ws.recv(), timeout=10)
|
| 89 |
+
msg = json.loads(raw)
|
| 90 |
+
|
| 91 |
+
assert msg.get("type") == "pong", \
|
| 92 |
+
f"Expected pong, got: {msg}"
|
| 93 |
+
|
| 94 |
+
record("WS ping/pong", True)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def test_invalid_json():
|
| 98 |
+
"""
|
| 99 |
+
Sends a non-JSON string and verifies the server returns an error message.
|
| 100 |
+
"""
|
| 101 |
+
async with websockets.connect(WS_URL, open_timeout=10) as ws:
|
| 102 |
+
await ws.send("this is not valid json {{{{")
|
| 103 |
+
|
| 104 |
+
raw = await asyncio.wait_for(ws.recv(), timeout=10)
|
| 105 |
+
msg = json.loads(raw)
|
| 106 |
+
|
| 107 |
+
assert msg.get("type") == "error", \
|
| 108 |
+
f"Expected error response, got: {msg}"
|
| 109 |
+
|
| 110 |
+
record("WS invalid JSON handling", True,
|
| 111 |
+
f"error_msg={msg.get('message', '')[:60]}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# βββ Runner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
|
| 116 |
+
async def main():
|
| 117 |
+
print(f"\n{'β'*60}")
|
| 118 |
+
print(f" BankBot WebSocket Validation β {WS_URL}")
|
| 119 |
+
print(f"{'β'*60}\n")
|
| 120 |
+
|
| 121 |
+
tests = [
|
| 122 |
+
("WS chat streaming", test_chat_streaming),
|
| 123 |
+
("WS ping/pong", test_ping_pong),
|
| 124 |
+
("WS invalid JSON handling", test_invalid_json),
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
for name, test_fn in tests:
|
| 128 |
+
try:
|
| 129 |
+
await test_fn()
|
| 130 |
+
except AssertionError as e:
|
| 131 |
+
record(name, False, str(e))
|
| 132 |
+
except Exception as e:
|
| 133 |
+
record(name, False, f"Exception: {type(e).__name__}: {e}")
|
| 134 |
+
|
| 135 |
+
# ββ Summary table βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
print(f"\n{'β'*60}")
|
| 137 |
+
print(f" {'TEST':<35} {'RESULT':<8} DETAIL")
|
| 138 |
+
print(f"{'β'*60}")
|
| 139 |
+
|
| 140 |
+
passed = 0
|
| 141 |
+
failed = 0
|
| 142 |
+
for test_name, ok, detail in results:
|
| 143 |
+
status = "β
PASS" if ok else "β FAIL"
|
| 144 |
+
print(f" {test_name:<35} {status:<8} {detail}")
|
| 145 |
+
if ok:
|
| 146 |
+
passed += 1
|
| 147 |
+
else:
|
| 148 |
+
failed += 1
|
| 149 |
+
|
| 150 |
+
print(f"{'β'*60}")
|
| 151 |
+
print(f" {passed} passed | {failed} failed | {len(results)} total")
|
| 152 |
+
print(f"{'β'*60}\n")
|
| 153 |
+
|
| 154 |
+
return failed
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
failed_count = asyncio.run(main())
|
| 159 |
+
sys.exit(0 if failed_count == 0 else 1)
|
backend/app/transactions/__init__.py
ADDED
|
File without changes
|
backend/app/transactions/router.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Transactions router β paginated transaction history with filtering.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from fastapi import APIRouter, Depends, Query
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from sqlalchemy import desc
|
| 8 |
+
|
| 9 |
+
from app.database.database import get_db
|
| 10 |
+
from app.database.models import User, Account, Transaction
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/api/transactions", tags=["Transactions"])
|
| 13 |
+
|
| 14 |
+
def _resolve_user(db: Session, user_id: Optional[str]) -> str:
|
| 15 |
+
if user_id:
|
| 16 |
+
return user_id
|
| 17 |
+
user = db.query(User).first()
|
| 18 |
+
if not user:
|
| 19 |
+
from fastapi import HTTPException
|
| 20 |
+
raise HTTPException(status_code=404, detail="No users found.")
|
| 21 |
+
return user.id
|
| 22 |
+
|
| 23 |
+
@router.get("/")
|
| 24 |
+
def get_transactions(
|
| 25 |
+
user_id: Optional[str] = None,
|
| 26 |
+
page: int = Query(default=1, ge=1),
|
| 27 |
+
limit: int = Query(default=20, ge=1, le=100),
|
| 28 |
+
category: Optional[str] = None,
|
| 29 |
+
type: Optional[str] = None,
|
| 30 |
+
db: Session = Depends(get_db),
|
| 31 |
+
):
|
| 32 |
+
uid = _resolve_user(db, user_id)
|
| 33 |
+
account_ids = [a.id for a in db.query(Account).filter(Account.user_id == uid).all()]
|
| 34 |
+
|
| 35 |
+
query = db.query(Transaction).filter(Transaction.account_id.in_(account_ids))
|
| 36 |
+
if category:
|
| 37 |
+
query = query.filter(Transaction.category == category)
|
| 38 |
+
if type:
|
| 39 |
+
query = query.filter(Transaction.type == type)
|
| 40 |
+
|
| 41 |
+
total = query.count()
|
| 42 |
+
transactions = query.order_by(desc(Transaction.timestamp)).offset((page - 1) * limit).limit(limit).all()
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
"transactions": [
|
| 46 |
+
{
|
| 47 |
+
"id": t.id,
|
| 48 |
+
"merchant": t.merchant or "Unknown",
|
| 49 |
+
"category": t.category or "Other",
|
| 50 |
+
"amount": t.amount if t.type == "credit" else -abs(t.amount),
|
| 51 |
+
"type": t.type,
|
| 52 |
+
"timestamp": t.timestamp.isoformat() if t.timestamp else None,
|
| 53 |
+
"tags": t.tags or [],
|
| 54 |
+
}
|
| 55 |
+
for t in transactions
|
| 56 |
+
],
|
| 57 |
+
"total": total,
|
| 58 |
+
"page": page,
|
| 59 |
+
"pages": (total + limit - 1) // limit,
|
| 60 |
+
}
|
backend/app/websocket/connection_manager.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List
|
| 2 |
+
from fastapi import WebSocket
|
| 3 |
+
|
| 4 |
+
class WebSocketConnectionManager:
|
| 5 |
+
def __init__(self):
|
| 6 |
+
# Maps user_id -> List[WebSocket]
|
| 7 |
+
self.active_connections: Dict[str, List[WebSocket]] = {}
|
| 8 |
+
|
| 9 |
+
async def connect(self, websocket: WebSocket, user_id: str):
|
| 10 |
+
await websocket.accept()
|
| 11 |
+
if user_id not in self.active_connections:
|
| 12 |
+
self.active_connections[user_id] = []
|
| 13 |
+
self.active_connections[user_id].append(websocket)
|
| 14 |
+
print(f"WebSocket client connected for user: {user_id}")
|
| 15 |
+
|
| 16 |
+
def disconnect(self, websocket: WebSocket, user_id: str):
|
| 17 |
+
if user_id in self.active_connections:
|
| 18 |
+
if websocket in self.active_connections[user_id]:
|
| 19 |
+
self.active_connections[user_id].remove(websocket)
|
| 20 |
+
if not self.active_connections[user_id]:
|
| 21 |
+
del self.active_connections[user_id]
|
| 22 |
+
print(f"WebSocket client disconnected for user: {user_id}")
|
| 23 |
+
|
| 24 |
+
async def send_personal_message(self, message: dict, user_id: str):
|
| 25 |
+
if user_id in self.active_connections:
|
| 26 |
+
for connection in self.active_connections[user_id]:
|
| 27 |
+
try:
|
| 28 |
+
await connection.send_json(message)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f"Error sending message to {user_id}: {e}")
|
| 31 |
+
|
| 32 |
+
async def broadcast(self, message: dict):
|
| 33 |
+
for user_id, connections in self.active_connections.items():
|
| 34 |
+
for connection in connections:
|
| 35 |
+
try:
|
| 36 |
+
await connection.send_json(message)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"Error broadcasting message: {e}")
|
| 39 |
+
|
| 40 |
+
# Global Connection Manager instance
|
| 41 |
+
ws_manager = WebSocketConnectionManager()
|
backend/app/websocket/router.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket chat router β real-time streaming AI chat with:
|
| 3 |
+
- Prompt injection prevention
|
| 4 |
+
- Input sanitization
|
| 5 |
+
- Heartbeat/ping support
|
| 6 |
+
- Structured error responses
|
| 7 |
+
- Observability metrics tracking
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import re
|
| 11 |
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
| 12 |
+
from app.database.database import SessionLocal
|
| 13 |
+
from app.database.models import User
|
| 14 |
+
from app.websocket.connection_manager import ws_manager
|
| 15 |
+
from app.ai.chat import stream_chat_response
|
| 16 |
+
from app.middleware.logging import ws_logger, metrics
|
| 17 |
+
|
| 18 |
+
router = APIRouter(tags=["WebSockets"])
|
| 19 |
+
|
| 20 |
+
# βββ Prompt injection patterns ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
_INJECTION_PATTERNS = [
|
| 22 |
+
r"ignore\s+(all\s+)?previous\s+instructions",
|
| 23 |
+
r"you\s+are\s+now\s+a",
|
| 24 |
+
r"forget\s+(everything|all)",
|
| 25 |
+
r"new\s+system\s+prompt",
|
| 26 |
+
r"disregard\s+(your|all)",
|
| 27 |
+
r"act\s+as\s+(if\s+you\s+are|a\s+different)",
|
| 28 |
+
r"jailbreak",
|
| 29 |
+
r"dan\s+mode",
|
| 30 |
+
r"developer\s+mode",
|
| 31 |
+
r"<\s*script",
|
| 32 |
+
r"javascript:",
|
| 33 |
+
]
|
| 34 |
+
_INJECTION_RE = re.compile("|".join(_INJECTION_PATTERNS), re.IGNORECASE)
|
| 35 |
+
|
| 36 |
+
MAX_MESSAGE_LENGTH = 2000
|
| 37 |
+
|
| 38 |
+
def sanitize_prompt(text: str) -> tuple[str, bool]:
|
| 39 |
+
"""
|
| 40 |
+
Returns (sanitized_text, is_safe).
|
| 41 |
+
Strips control characters, checks for injection patterns.
|
| 42 |
+
"""
|
| 43 |
+
# Strip null bytes and control characters (keep newlines/tabs)
|
| 44 |
+
cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
|
| 45 |
+
# Truncate
|
| 46 |
+
cleaned = cleaned[:MAX_MESSAGE_LENGTH]
|
| 47 |
+
# Check for injection
|
| 48 |
+
if _INJECTION_RE.search(cleaned):
|
| 49 |
+
return cleaned, False
|
| 50 |
+
return cleaned, True
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.websocket("/api/ai/chat/ws")
|
| 54 |
+
async def websocket_chat_endpoint(
|
| 55 |
+
websocket: WebSocket,
|
| 56 |
+
user_id: str = Query(None),
|
| 57 |
+
):
|
| 58 |
+
db = SessionLocal()
|
| 59 |
+
|
| 60 |
+
# Resolve user
|
| 61 |
+
if not user_id:
|
| 62 |
+
user = db.query(User).first()
|
| 63 |
+
if user:
|
| 64 |
+
user_id = user.id
|
| 65 |
+
else:
|
| 66 |
+
await websocket.accept()
|
| 67 |
+
await websocket.send_json({
|
| 68 |
+
"type": "error",
|
| 69 |
+
"message": "No users found. Run: python app/scripts/seed_demo.py"
|
| 70 |
+
})
|
| 71 |
+
await websocket.close()
|
| 72 |
+
db.close()
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
await ws_manager.connect(websocket, user_id)
|
| 76 |
+
metrics.ws_connects += 1
|
| 77 |
+
ws_logger.info("WebSocket connected", extra={"user_id": user_id[:8]})
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
while True:
|
| 81 |
+
data = await websocket.receive_text()
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
payload = json.loads(data)
|
| 85 |
+
except json.JSONDecodeError:
|
| 86 |
+
await websocket.send_json({"type": "error", "message": "Invalid JSON"})
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
msg_type = payload.get("type", "chat")
|
| 90 |
+
|
| 91 |
+
# ββ Heartbeat ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
+
if msg_type == "ping":
|
| 93 |
+
await websocket.send_json({"type": "pong"})
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
# ββ Chat message βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
if msg_type == "chat":
|
| 98 |
+
raw_prompt = payload.get("message", "").strip()
|
| 99 |
+
|
| 100 |
+
if not raw_prompt:
|
| 101 |
+
await websocket.send_json({"type": "error", "message": "Message cannot be empty"})
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
# Sanitize + injection check
|
| 105 |
+
prompt, is_safe = sanitize_prompt(raw_prompt)
|
| 106 |
+
if not is_safe:
|
| 107 |
+
ws_logger.warning("Prompt injection attempt blocked", extra={"user_id": user_id[:8]})
|
| 108 |
+
await websocket.send_json({
|
| 109 |
+
"type": "error",
|
| 110 |
+
"message": "I can only help with financial questions about your accounts."
|
| 111 |
+
})
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
await websocket.send_json({"type": "chat_start"})
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
for chunk in stream_chat_response(db, user_id, prompt):
|
| 118 |
+
if chunk:
|
| 119 |
+
await websocket.send_json({"type": "chat_chunk", "content": chunk})
|
| 120 |
+
except Exception as e:
|
| 121 |
+
ws_logger.error("AI streaming error", extra={"error": str(e)[:100]})
|
| 122 |
+
await websocket.send_json({
|
| 123 |
+
"type": "error",
|
| 124 |
+
"message": "AI response failed. Please try again."
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
await websocket.send_json({"type": "chat_end"})
|
| 128 |
+
|
| 129 |
+
else:
|
| 130 |
+
await websocket.send_json({
|
| 131 |
+
"type": "error",
|
| 132 |
+
"message": f"Unknown message type: {msg_type}"
|
| 133 |
+
})
|
| 134 |
+
|
| 135 |
+
except WebSocketDisconnect:
|
| 136 |
+
ws_manager.disconnect(websocket, user_id)
|
| 137 |
+
ws_logger.info("WebSocket disconnected", extra={"user_id": user_id[:8]})
|
| 138 |
+
except Exception as e:
|
| 139 |
+
ws_logger.error("WebSocket error", extra={"user_id": user_id[:8], "error": str(e)[:100]})
|
| 140 |
+
ws_manager.disconnect(websocket, user_id)
|
| 141 |
+
finally:
|
| 142 |
+
db.close()
|