mohsin-devs commited on
Commit
a282d4b
Β·
0 Parent(s):

Deploy to HF

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +37 -0
  2. .env.example +54 -0
  3. .gitattributes +35 -0
  4. .github/workflows/ci.yml +114 -0
  5. .gitignore +29 -0
  6. .kiro/specs/bankbot-ai-intelligence/.config.kiro +1 -0
  7. .kiro/specs/bankbot-ai-intelligence/design.md +1393 -0
  8. .kiro/specs/bankbot-ai-intelligence/tasks.md +286 -0
  9. .vscode/settings.json +2 -0
  10. Dockerfile +101 -0
  11. README.md +186 -0
  12. backend/Dockerfile +44 -0
  13. backend/alembic.ini +80 -0
  14. backend/alembic/env.py +81 -0
  15. backend/alembic/script.py.mako +26 -0
  16. backend/app/__init__.py +0 -0
  17. backend/app/ai/behavior.py +138 -0
  18. backend/app/ai/budget_planner.py +303 -0
  19. backend/app/ai/chat.py +289 -0
  20. backend/app/ai/coaching.py +244 -0
  21. backend/app/ai/forecasting.py +182 -0
  22. backend/app/ai/fraud.py +123 -0
  23. backend/app/ai/fraud_detection.py +286 -0
  24. backend/app/ai/loan_prediction_model.pkl +3 -0
  25. backend/app/ai/loan_predictor.py +301 -0
  26. backend/app/ai/ollama_integration.py +369 -0
  27. backend/app/ai/router.py +181 -0
  28. backend/app/ai/simulation.py +204 -0
  29. backend/app/ai/subscriptions.py +105 -0
  30. backend/app/ai/voice_assistant.py +219 -0
  31. backend/app/auth/__init__.py +0 -0
  32. backend/app/auth/router.py +189 -0
  33. backend/app/dashboard/__init__.py +0 -0
  34. backend/app/dashboard/router.py +189 -0
  35. backend/app/database/database.py +42 -0
  36. backend/app/database/models.py +147 -0
  37. backend/app/main.py +171 -0
  38. backend/app/middleware/__init__.py +0 -0
  39. backend/app/middleware/cache.py +86 -0
  40. backend/app/middleware/logging.py +184 -0
  41. backend/app/notifications/__init__.py +0 -0
  42. backend/app/notifications/router.py +90 -0
  43. backend/app/scripts/seed.py +194 -0
  44. backend/app/scripts/seed_demo.py +300 -0
  45. backend/app/scripts/test_endpoints.py +249 -0
  46. backend/app/scripts/test_websocket.py +159 -0
  47. backend/app/transactions/__init__.py +0 -0
  48. backend/app/transactions/router.py +60 -0
  49. backend/app/websocket/connection_manager.py +41 -0
  50. 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
+ [![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
19
+ [![Next.js](https://img.shields.io/badge/Next.js_14-black?style=flat-square&logo=next.js&logoColor=white)](https://nextjs.org)
20
+ [![Python](https://img.shields.io/badge/Python_3.11-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org)
21
+ [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://typescriptlang.org)
22
+ [![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)](https://docker.com)
23
+ [![OpenAI](https://img.shields.io/badge/OpenAI-412991?style=flat-square&logo=openai&logoColor=white)](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()