nothingworry commited on
Commit
611e2c1
Β·
1 Parent(s): 752b9a7

Migrate admin rules and analytics to Supabase

Browse files
README.md CHANGED
@@ -31,7 +31,7 @@ This platform showcases how MCP can power intelligent, governed, multi-tenant AI
31
  - **Rule-Based Behavior Control**: Rules checked FIRST - brief response rules return quick answers, blocking rules prevent requests
32
  - **Comment Filtering**: Comment lines (starting with #) automatically ignored when uploading rules
33
  - **Supabase Integration**: Rules stored in Supabase for production scalability (with SQLite fallback)
34
- - πŸ“Š **Comprehensive Analytics & Observability** – Full tenant-level analytics logging with SQLite backend:
35
  - Tool usage breakdown (RAG, Web, Admin, LLM) with latency and token tracking
36
  - RAG recall/precision indicators (average hits, scores, top scores)
37
  - Per-tenant query volume and active users
@@ -53,7 +53,7 @@ This platform showcases how MCP can power intelligent, governed, multi-tenant AI
53
  - πŸ“ˆ **Real-Time Analytics Dashboard** – Per-tenant analytics with configurable time windows (7, 30, 90 days)
54
  - πŸ› οΈ **Admin API Endpoints** – `/admin/violations`, `/admin/tools/logs`, `/admin/tenants` for comprehensive governance
55
  - 🧠 **Agent Debug & Planning** – `/agent/debug` and `/agent/plan` endpoints for observability and tool selection inspection
56
- - πŸ’Ύ **Persistent Analytics Storage** – SQLite-based analytics store with indexes for fast queries
57
  - πŸ—„οΈ **Supabase Integration** – Production-ready Supabase support for admin rules with automatic table creation
58
 
59
  ---
 
31
  - **Rule-Based Behavior Control**: Rules checked FIRST - brief response rules return quick answers, blocking rules prevent requests
32
  - **Comment Filtering**: Comment lines (starting with #) automatically ignored when uploading rules
33
  - **Supabase Integration**: Rules stored in Supabase for production scalability (with SQLite fallback)
34
+ - πŸ“Š **Comprehensive Analytics & Observability** – Full tenant-level analytics logging with Supabase backend (SQLite fallback for local dev):
35
  - Tool usage breakdown (RAG, Web, Admin, LLM) with latency and token tracking
36
  - RAG recall/precision indicators (average hits, scores, top scores)
37
  - Per-tenant query volume and active users
 
53
  - πŸ“ˆ **Real-Time Analytics Dashboard** – Per-tenant analytics with configurable time windows (7, 30, 90 days)
54
  - πŸ› οΈ **Admin API Endpoints** – `/admin/violations`, `/admin/tools/logs`, `/admin/tenants` for comprehensive governance
55
  - 🧠 **Agent Debug & Planning** – `/agent/debug` and `/agent/plan` endpoints for observability and tool selection inspection
56
+ - πŸ’Ύ **Persistent Analytics Storage** – Supabase-backed analytics store (with automatic SQLite fallback) for fast, multi-tenant queries
57
  - πŸ—„οΈ **Supabase Integration** – Production-ready Supabase support for admin rules with automatic table creation
58
 
59
  ---
SUPABASE_MIGRATION_COMPLETE.md ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase Migration Complete βœ…
2
+
3
+ After running the migration, your data is now in Supabase. This document explains how to ensure **all future data** is saved to Supabase instead of SQLite.
4
+
5
+ ## βœ… What's Already Configured
6
+
7
+ Both `RulesStore` and `AnalyticsStore` automatically detect and use Supabase when credentials are available. They will:
8
+
9
+ 1. **Check for Supabase credentials** in your `.env` file
10
+ 2. **Use Supabase if available** (preferred)
11
+ 3. **Fall back to SQLite** only if Supabase is not configured
12
+
13
+ ## πŸ”§ Required Configuration
14
+
15
+ To ensure Supabase is used for all future data, make sure your `.env` file has:
16
+
17
+ ```env
18
+ # Required for runtime (REST API)
19
+ SUPABASE_URL=https://your-project-id.supabase.co
20
+ SUPABASE_SERVICE_KEY=your_service_role_key_here
21
+
22
+ # Optional: For direct PostgreSQL connection (migration only)
23
+ POSTGRESQL_URL=postgresql://postgres:password@db.xxxxx.supabase.co:5432/postgres
24
+ ```
25
+
26
+ **Important:**
27
+ - `SUPABASE_URL` and `SUPABASE_SERVICE_KEY` are **required** for runtime
28
+ - `POSTGRESQL_URL` is optional (only needed for migration script)
29
+ - Both stores use the Supabase REST API at runtime, not direct PostgreSQL
30
+
31
+ ## βœ… Verify Configuration
32
+
33
+ Run the verification script to confirm Supabase is configured:
34
+
35
+ ```bash
36
+ python verify_supabase_setup.py
37
+ ```
38
+
39
+ This will show:
40
+ - βœ… Which backend each store is using
41
+ - ⚠️ Any missing configuration
42
+ - πŸ“‹ Summary of what will be saved where
43
+
44
+ ## πŸš€ After Configuration
45
+
46
+ 1. **Restart your services:**
47
+ ```bash
48
+ # Stop your FastAPI server
49
+ # Stop your MCP server
50
+ # Then restart them
51
+ ```
52
+
53
+ 2. **Check startup logs:**
54
+ You should see messages like:
55
+ ```
56
+ βœ… RulesStore: Using Supabase backend
57
+ βœ… AnalyticsStore: Using Supabase backend
58
+ βœ… AgentOrchestrator Analytics: Using Supabase backend
59
+ ```
60
+
61
+ 3. **Test by adding data:**
62
+ - Add a rule via the admin panel
63
+ - Make a query to generate analytics
64
+ - Check Supabase Dashboard β†’ Table Editor to verify data appears
65
+
66
+ ## πŸ“Š Where Data is Saved
67
+
68
+ | Data Type | Storage Location | Configuration |
69
+ |-----------|-----------------|---------------|
70
+ | Admin Rules | Supabase `admin_rules` table | `SUPABASE_URL` + `SUPABASE_SERVICE_KEY` |
71
+ | Analytics Events | Supabase analytics tables | `SUPABASE_URL` + `SUPABASE_SERVICE_KEY` |
72
+ | Tool Usage | Supabase `tool_usage_events` | `SUPABASE_URL` + `SUPABASE_SERVICE_KEY` |
73
+ | Red Flags | Supabase `redflag_violations` | `SUPABASE_URL` + `SUPABASE_SERVICE_KEY` |
74
+ | RAG Searches | Supabase `rag_search_events` | `SUPABASE_URL` + `SUPABASE_SERVICE_KEY` |
75
+ | Agent Queries | Supabase `agent_query_events` | `SUPABASE_URL` + `SUPABASE_SERVICE_KEY` |
76
+
77
+ ## πŸ” Troubleshooting
78
+
79
+ ### Data still going to SQLite?
80
+
81
+ 1. **Check your `.env` file:**
82
+ ```bash
83
+ # Make sure these are set (no quotes, no spaces)
84
+ SUPABASE_URL=https://xxxxx.supabase.co
85
+ SUPABASE_SERVICE_KEY=eyJ... (full key)
86
+ ```
87
+
88
+ 2. **Verify credentials:**
89
+ ```bash
90
+ python verify_supabase_key.py
91
+ ```
92
+
93
+ 3. **Check startup logs:**
94
+ Look for warnings like:
95
+ ```
96
+ ⚠️ RulesStore: Using SQLite backend
97
+ ```
98
+ This means Supabase credentials are missing or invalid.
99
+
100
+ 4. **Restart services:**
101
+ Environment variables are loaded at startup. After changing `.env`, restart your services.
102
+
103
+ ### Tables don't exist?
104
+
105
+ If you see errors about missing tables:
106
+
107
+ 1. Go to Supabase Dashboard β†’ SQL Editor
108
+ 2. Run `supabase_admin_rules_table.sql` (for rules)
109
+ 3. Run `supabase_analytics_tables.sql` (for analytics)
110
+
111
+ ### API Key errors?
112
+
113
+ - Make sure you're using the **service_role** key (not anon key)
114
+ - Key should be ~200+ characters long
115
+ - No quotes or spaces around the value in `.env`
116
+
117
+ ## πŸ“ Summary
118
+
119
+ βœ… **Migration complete** - Your existing data is in Supabase
120
+ βœ… **Auto-detection enabled** - Stores automatically use Supabase when configured
121
+ βœ… **Startup logging** - You'll see which backend is being used
122
+ βœ… **Verification script** - Run `verify_supabase_setup.py` to check configuration
123
+
124
+ **Next time you add rules or generate analytics, they will automatically be saved to Supabase!** πŸŽ‰
125
+
SUPABASE_SETUP.md CHANGED
@@ -73,27 +73,35 @@ print(f"Rules: {rules}")
73
  2. Select the `admin_rules` table
74
  3. You should see all your rules with tenant isolation
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  ## Migration from SQLite
77
 
78
- If you have existing rules in SQLite and want to migrate:
79
-
80
- 1. Export from SQLite:
81
- ```python
82
- import sqlite3
83
- conn = sqlite3.connect('data/admin_rules.db')
84
- cursor = conn.execute("SELECT * FROM admin_rules")
85
- rules = cursor.fetchall()
86
- ```
87
-
88
- 2. Import to Supabase:
89
- ```python
90
- from backend.api.storage.rules_store import RulesStore
91
- store = RulesStore(use_supabase=True)
92
- for rule in rules:
93
- store.add_rule(rule['tenant_id'], rule['rule'],
94
- pattern=rule.get('pattern'),
95
- severity=rule.get('severity', 'medium'))
96
- ```
97
 
98
  ## Troubleshooting
99
 
 
73
  2. Select the `admin_rules` table
74
  3. You should see all your rules with tenant isolation
75
 
76
+ ## Supabase Analytics Tables
77
+
78
+ To move analytics off SQLite, create the Supabase tables that mirror the local schema:
79
+
80
+ 1. Open the Supabase SQL Editor.
81
+ 2. Copy the contents of `supabase_analytics_tables.sql`.
82
+ 3. Run the script. It creates the following tables with indexes + RLS policies:
83
+ - `tool_usage_events`
84
+ - `redflag_violations`
85
+ - `rag_search_events`
86
+ - `agent_query_events`
87
+
88
+ After the tables exist, the backend automatically detects Supabase credentials and writes analytics there (falling back to SQLite only when credentials or the Supabase client are missing).
89
+
90
  ## Migration from SQLite
91
 
92
+ If you already have local data that should be moved to Supabase, use the helper script:
93
+
94
+ ```bash
95
+ python migrate_sqlite_to_supabase.py
96
+ ```
97
+
98
+ The script:
99
+ - Loads `.env` for Supabase credentials
100
+ - Copies `data/admin_rules.db` β†’ `admin_rules`
101
+ - Copies all analytics tables in `data/analytics.db` β†’ Supabase equivalents
102
+ - Skips tables that already contain Supabase rows (pass `--force` to override)
103
+
104
+ > **Tip:** Back up your SQLite databases before migrating. The script does not delete local data.
 
 
 
 
 
 
105
 
106
  ## Troubleshooting
107
 
backend/README.md CHANGED
@@ -12,7 +12,7 @@ This folder contains the production-ready FastAPI stack plus the companion MCP s
12
 
13
  - Python 3.10+
14
  - PostgreSQL (with the `vector` extension) for RAG data, or Supabase with pgvector enabled
15
- - SQLite (auto-created in `data/`) for analytics and admin rules
16
  - Optional: Ollama running locally (default) or Groq API credentials for remote LLMs
17
 
18
  Create a virtual environment at the repo root, then:
 
12
 
13
  - Python 3.10+
14
  - PostgreSQL (with the `vector` extension) for RAG data, or Supabase with pgvector enabled
15
+ - Supabase (preferred) for admin rules + analytics, with automatic SQLite fallback in `data/`
16
  - Optional: Ollama running locally (default) or Groq API credentials for remote LLMs
17
 
18
  Create a virtual environment at the repo root, then:
backend/api/routes/admin.py CHANGED
@@ -15,6 +15,17 @@ rules_store = RulesStore(auto_create_table=False)
15
  analytics_store = AnalyticsStore()
16
  rule_enhancer = RuleEnhancer()
17
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  class RulePayload(BaseModel):
20
  rule: str
 
15
  analytics_store = AnalyticsStore()
16
  rule_enhancer = RuleEnhancer()
17
 
18
+ # Log which backend is being used
19
+ if rules_store.use_supabase:
20
+ print("βœ… RulesStore: Using Supabase backend")
21
+ else:
22
+ print("⚠️ RulesStore: Using SQLite backend (set SUPABASE_URL + SUPABASE_SERVICE_KEY to use Supabase)")
23
+
24
+ if analytics_store.use_supabase:
25
+ print("βœ… AnalyticsStore: Using Supabase backend")
26
+ else:
27
+ print("⚠️ AnalyticsStore: Using SQLite backend (set SUPABASE_URL + SUPABASE_SERVICE_KEY to use Supabase)")
28
+
29
 
30
  class RulePayload(BaseModel):
31
  rule: str
backend/api/services/agent_orchestrator.py CHANGED
@@ -44,6 +44,13 @@ class AgentOrchestrator:
44
  self.selector = ToolSelector(llm_client=self.llm)
45
  self.tool_scorer = ToolScoringService()
46
  self.analytics = AnalyticsStore()
 
 
 
 
 
 
 
47
 
48
  async def handle(self, req: AgentRequest) -> AgentResponse:
49
  start_time = time.time()
 
44
  self.selector = ToolSelector(llm_client=self.llm)
45
  self.tool_scorer = ToolScoringService()
46
  self.analytics = AnalyticsStore()
47
+ # Log backend being used (only once at startup)
48
+ if not hasattr(AgentOrchestrator, '_analytics_backend_logged'):
49
+ if self.analytics.use_supabase:
50
+ print("βœ… AgentOrchestrator Analytics: Using Supabase backend")
51
+ else:
52
+ print("⚠️ AgentOrchestrator Analytics: Using SQLite backend")
53
+ AgentOrchestrator._analytics_backend_logged = True
54
 
55
  async def handle(self, req: AgentRequest) -> AgentResponse:
56
  start_time = time.time()
backend/api/storage/analytics_store.py CHANGED
@@ -9,21 +9,55 @@ Tracks:
9
  - Per-tenant query volume
10
  """
11
 
12
- import sqlite3
13
  import json
 
 
14
  import time
15
- from pathlib import Path
16
- from typing import List, Dict, Any, Optional
17
  from datetime import datetime
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  class AnalyticsStore:
21
  """
22
- SQLite-backed store for analytics logging.
23
- Provides tenant-level analytics for tool usage, tokens, latency, and violations.
 
 
24
  """
25
 
26
- def __init__(self, db_path: Optional[str] = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  if db_path is None:
28
  root_dir = Path(__file__).resolve().parents[3]
29
  data_dir = root_dir / "data"
@@ -31,14 +65,71 @@ class AnalyticsStore:
31
  self.db_path = data_dir / "analytics.db"
32
  else:
33
  self.db_path = Path(db_path)
34
-
35
  self._init_db()
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def _init_db(self):
38
  """Initialize database tables for analytics."""
39
  with sqlite3.connect(self.db_path) as conn:
40
  # Tool usage events table
41
- conn.execute("""
 
42
  CREATE TABLE IF NOT EXISTS tool_usage_events (
43
  id INTEGER PRIMARY KEY AUTOINCREMENT,
44
  tenant_id TEXT NOT NULL,
@@ -51,10 +142,12 @@ class AnalyticsStore:
51
  error_message TEXT,
52
  metadata TEXT
53
  )
54
- """)
55
-
 
56
  # Red-flag violations table
57
- conn.execute("""
 
58
  CREATE TABLE IF NOT EXISTS redflag_violations (
59
  id INTEGER PRIMARY KEY AUTOINCREMENT,
60
  tenant_id TEXT NOT NULL,
@@ -67,10 +160,12 @@ class AnalyticsStore:
67
  message_preview TEXT,
68
  timestamp INTEGER NOT NULL
69
  )
70
- """)
71
-
 
72
  # RAG search events with quality metrics
73
- conn.execute("""
 
74
  CREATE TABLE IF NOT EXISTS rag_search_events (
75
  id INTEGER PRIMARY KEY AUTOINCREMENT,
76
  tenant_id TEXT NOT NULL,
@@ -81,10 +176,12 @@ class AnalyticsStore:
81
  timestamp INTEGER NOT NULL,
82
  latency_ms INTEGER
83
  )
84
- """)
85
-
 
86
  # Agent query events (overall query tracking)
87
- conn.execute("""
 
88
  CREATE TABLE IF NOT EXISTS agent_query_events (
89
  id INTEGER PRIMARY KEY AUTOINCREMENT,
90
  tenant_id TEXT NOT NULL,
@@ -97,31 +194,122 @@ class AnalyticsStore:
97
  success BOOLEAN DEFAULT 1,
98
  timestamp INTEGER NOT NULL
99
  )
100
- """)
101
-
 
102
  # Create indexes separately (SQLite doesn't support inline INDEX in CREATE TABLE)
103
- conn.execute("""
 
104
  CREATE INDEX IF NOT EXISTS idx_tool_usage_tenant_timestamp
105
  ON tool_usage_events(tenant_id, timestamp)
106
- """)
107
-
108
- conn.execute("""
 
 
109
  CREATE INDEX IF NOT EXISTS idx_redflag_tenant_timestamp
110
  ON redflag_violations(tenant_id, timestamp)
111
- """)
112
-
113
- conn.execute("""
 
 
114
  CREATE INDEX IF NOT EXISTS idx_rag_search_tenant_timestamp
115
  ON rag_search_events(tenant_id, timestamp)
116
- """)
117
-
118
- conn.execute("""
 
 
119
  CREATE INDEX IF NOT EXISTS idx_agent_query_tenant_timestamp
120
  ON agent_query_events(tenant_id, timestamp)
121
- """)
122
-
 
123
  conn.commit()
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def log_tool_usage(
126
  self,
127
  tenant_id: str,
@@ -134,22 +322,40 @@ class AnalyticsStore:
134
  user_id: Optional[str] = None
135
  ):
136
  """Log a tool usage event."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  with sqlite3.connect(self.db_path) as conn:
138
- conn.execute("""
 
139
  INSERT INTO tool_usage_events
140
  (tenant_id, user_id, tool_name, timestamp, latency_ms, tokens_used, success, error_message, metadata)
141
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
142
- """, (
143
- tenant_id,
144
- user_id,
145
- tool_name,
146
- int(time.time()),
147
- latency_ms,
148
- tokens_used,
149
- 1 if success else 0,
150
- error_message,
151
- json.dumps(metadata) if metadata else None
152
- ))
 
 
153
  conn.commit()
154
 
155
  def log_redflag_violation(
@@ -164,22 +370,42 @@ class AnalyticsStore:
164
  user_id: Optional[str] = None
165
  ):
166
  """Log a red-flag violation."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  with sqlite3.connect(self.db_path) as conn:
168
- conn.execute("""
 
169
  INSERT INTO redflag_violations
170
  (tenant_id, user_id, rule_id, rule_pattern, severity, matched_text, confidence, message_preview, timestamp)
171
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
172
- """, (
173
- tenant_id,
174
- user_id,
175
- rule_id,
176
- rule_pattern,
177
- severity,
178
- matched_text,
179
- confidence,
180
- message_preview[:200] if message_preview else None,
181
- int(time.time())
182
- ))
 
 
183
  conn.commit()
184
 
185
  def log_rag_search(
@@ -192,20 +418,37 @@ class AnalyticsStore:
192
  latency_ms: Optional[int] = None
193
  ):
194
  """Log a RAG search event with quality metrics."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  with sqlite3.connect(self.db_path) as conn:
196
- conn.execute("""
 
197
  INSERT INTO rag_search_events
198
  (tenant_id, query, hits_count, avg_score, top_score, timestamp, latency_ms)
199
  VALUES (?, ?, ?, ?, ?, ?, ?)
200
- """, (
201
- tenant_id,
202
- query[:500], # Limit query length
203
- hits_count,
204
- avg_score,
205
- top_score,
206
- int(time.time()),
207
- latency_ms
208
- ))
 
 
209
  conn.commit()
210
 
211
  def log_agent_query(
@@ -220,22 +463,43 @@ class AnalyticsStore:
220
  user_id: Optional[str] = None
221
  ):
222
  """Log an agent query event (overall query tracking)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  with sqlite3.connect(self.db_path) as conn:
224
- conn.execute("""
 
225
  INSERT INTO agent_query_events
226
  (tenant_id, user_id, message_preview, intent, tools_used, total_tokens, total_latency_ms, success, timestamp)
227
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
228
- """, (
229
- tenant_id,
230
- user_id,
231
- message_preview[:200],
232
- intent,
233
- json.dumps(tools_used) if tools_used else None,
234
- total_tokens,
235
- total_latency_ms,
236
- 1 if success else 0,
237
- int(time.time())
238
- ))
 
 
239
  conn.commit()
240
 
241
  def get_tool_usage_stats(
@@ -244,42 +508,78 @@ class AnalyticsStore:
244
  since_timestamp: Optional[int] = None
245
  ) -> Dict[str, Any]:
246
  """Get tool usage statistics for a tenant."""
247
- with sqlite3.connect(self.db_path) as conn:
248
- conn.row_factory = sqlite3.Row
249
-
250
- query = """
251
- SELECT
252
- tool_name,
253
- COUNT(*) as count,
254
- AVG(latency_ms) as avg_latency_ms,
255
- SUM(tokens_used) as total_tokens,
256
- SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count
257
- FROM tool_usage_events
258
- WHERE tenant_id = ?
259
- """
260
- params = [tenant_id]
261
-
262
- if since_timestamp:
263
- query += " AND timestamp >= ?"
264
- params.append(since_timestamp)
265
-
266
- query += " GROUP BY tool_name"
267
-
268
- cursor = conn.execute(query, params)
269
- rows = cursor.fetchall()
270
-
271
- stats = {}
272
- for row in rows:
273
- tool_name = row["tool_name"]
274
- stats[tool_name] = {
275
- "count": row["count"],
276
- "avg_latency_ms": round(row["avg_latency_ms"] or 0, 2),
277
- "total_tokens": row["total_tokens"] or 0,
278
- "success_count": row["success_count"],
279
- "error_count": row["count"] - row["success_count"]
280
- }
281
-
282
- return stats
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
  def get_redflag_violations(
285
  self,
@@ -288,25 +588,35 @@ class AnalyticsStore:
288
  since_timestamp: Optional[int] = None
289
  ) -> List[Dict[str, Any]]:
290
  """Get recent red-flag violations for a tenant."""
 
 
 
 
 
 
 
 
 
 
291
  with sqlite3.connect(self.db_path) as conn:
292
  conn.row_factory = sqlite3.Row
293
-
294
  query = """
295
  SELECT * FROM redflag_violations
296
  WHERE tenant_id = ?
297
  """
298
  params = [tenant_id]
299
-
300
  if since_timestamp:
301
  query += " AND timestamp >= ?"
302
  params.append(since_timestamp)
303
-
304
  query += " ORDER BY timestamp DESC LIMIT ?"
305
  params.append(limit)
306
-
307
  cursor = conn.execute(query, params)
308
  rows = cursor.fetchall()
309
-
310
  return [dict(row) for row in rows]
311
 
312
  def get_activity_summary(
@@ -315,18 +625,40 @@ class AnalyticsStore:
315
  since_timestamp: Optional[int] = None
316
  ) -> Dict[str, Any]:
317
  """Get activity summary for a tenant."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  with sqlite3.connect(self.db_path) as conn:
319
  conn.row_factory = sqlite3.Row
320
-
321
  # Total queries
322
  query = "SELECT COUNT(*) as total FROM agent_query_events WHERE tenant_id = ?"
323
  params = [tenant_id]
324
  if since_timestamp:
325
  query += " AND timestamp >= ?"
326
  params.append(since_timestamp)
327
-
328
  total_queries = conn.execute(query, params).fetchone()["total"]
329
-
330
  # Active users (unique user_ids in the period)
331
  query = """
332
  SELECT COUNT(DISTINCT user_id) as active_users
@@ -337,9 +669,9 @@ class AnalyticsStore:
337
  if since_timestamp:
338
  query += " AND timestamp >= ?"
339
  params.append(since_timestamp)
340
-
341
  active_users = conn.execute(query, params).fetchone()["active_users"]
342
-
343
  # Last query timestamp
344
  query = """
345
  SELECT MAX(timestamp) as last_query
@@ -347,21 +679,23 @@ class AnalyticsStore:
347
  WHERE tenant_id = ?
348
  """
349
  last_query_ts = conn.execute(query, [tenant_id]).fetchone()["last_query"]
350
-
351
  # Red-flag count
352
  query = "SELECT COUNT(*) as count FROM redflag_violations WHERE tenant_id = ?"
353
  params = [tenant_id]
354
  if since_timestamp:
355
  query += " AND timestamp >= ?"
356
  params.append(since_timestamp)
357
-
358
  redflag_count = conn.execute(query, params).fetchone()["count"]
359
-
360
  return {
361
  "total_queries": total_queries,
362
  "active_users": active_users or 0,
363
  "redflag_count": redflag_count,
364
- "last_query": datetime.fromtimestamp(last_query_ts).isoformat() if last_query_ts else None
 
 
365
  }
366
 
367
  def get_rag_quality_metrics(
@@ -370,9 +704,37 @@ class AnalyticsStore:
370
  since_timestamp: Optional[int] = None
371
  ) -> Dict[str, Any]:
372
  """Get RAG quality metrics (recall/precision indicators)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  with sqlite3.connect(self.db_path) as conn:
374
  conn.row_factory = sqlite3.Row
375
-
376
  query = """
377
  SELECT
378
  COUNT(*) as total_searches,
@@ -384,18 +746,18 @@ class AnalyticsStore:
384
  WHERE tenant_id = ?
385
  """
386
  params = [tenant_id]
387
-
388
  if since_timestamp:
389
  query += " AND timestamp >= ?"
390
  params.append(since_timestamp)
391
-
392
  row = conn.execute(query, params).fetchone()
393
-
394
  return {
395
  "total_searches": row["total_searches"] or 0,
396
  "avg_hits_per_search": round(row["avg_hits"] or 0, 2),
397
  "avg_score": round(row["avg_avg_score"] or 0, 3),
398
  "avg_top_score": round(row["avg_top_score"] or 0, 3),
399
- "avg_latency_ms": round(row["avg_latency_ms"] or 0, 2)
400
  }
401
 
 
9
  - Per-tenant query volume
10
  """
11
 
 
12
  import json
13
+ import os
14
+ import sqlite3
15
  import time
 
 
16
  from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ try:
21
+ from supabase import Client, create_client
22
+
23
+ SUPABASE_AVAILABLE = True
24
+ except ImportError:
25
+ Client = None # type: ignore
26
+ SUPABASE_AVAILABLE = False
27
 
28
 
29
  class AnalyticsStore:
30
  """
31
+ Analytics logging with dual-backend support.
32
+
33
+ - Uses Supabase when SUPABASE_URL/SUPABASE_SERVICE_KEY are configured
34
+ - Falls back to local SQLite (data/analytics.db) otherwise
35
  """
36
 
37
+ def __init__(
38
+ self,
39
+ db_path: Optional[str] = None,
40
+ use_supabase: Optional[bool] = None,
41
+ auto_create_tables: bool = False,
42
+ ):
43
+ self.use_supabase = use_supabase
44
+ self._tables_verified = False
45
+ self.supabase_client: Optional[Client] = None
46
+
47
+ if self.use_supabase is None:
48
+ supabase_url = os.getenv("SUPABASE_URL")
49
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
50
+ self.use_supabase = bool(
51
+ supabase_url and supabase_key and SUPABASE_AVAILABLE
52
+ )
53
+
54
+ if self.use_supabase:
55
+ self._init_supabase(auto_create_tables)
56
+ else:
57
+ self._init_sqlite(db_path)
58
+
59
+ def _init_sqlite(self, db_path: Optional[str]):
60
+ """Initialize SQLite database path + schema."""
61
  if db_path is None:
62
  root_dir = Path(__file__).resolve().parents[3]
63
  data_dir = root_dir / "data"
 
65
  self.db_path = data_dir / "analytics.db"
66
  else:
67
  self.db_path = Path(db_path)
68
+
69
  self._init_db()
70
 
71
+ def _init_supabase(self, auto_create_tables: bool):
72
+ supabase_url = os.getenv("SUPABASE_URL")
73
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
74
+
75
+ if not supabase_url or not supabase_key:
76
+ print("⚠️ Supabase credentials missing. Falling back to SQLite for analytics.")
77
+ self.use_supabase = False
78
+ self._init_sqlite(None)
79
+ return
80
+
81
+ try:
82
+ self.supabase_client = create_client(supabase_url, supabase_key)
83
+ self.table_names = {
84
+ "tool_usage": "tool_usage_events",
85
+ "redflags": "redflag_violations",
86
+ "rag_search": "rag_search_events",
87
+ "agent_query": "agent_query_events",
88
+ }
89
+
90
+ if auto_create_tables:
91
+ self._ensure_supabase_tables()
92
+ else:
93
+ self._quick_table_check()
94
+ except Exception as exc: # pragma: no cover - defensive logging
95
+ print(f"⚠️ Failed to initialize Supabase client for analytics: {exc}")
96
+ self.use_supabase = False
97
+ self._init_sqlite(None)
98
+
99
+ def _quick_table_check(self):
100
+ """Verify that all expected Supabase tables exist."""
101
+ if not self.supabase_client:
102
+ return
103
+ try:
104
+ for table in self.table_names.values():
105
+ # PostgREST select throws if table missing
106
+ self.supabase_client.table(table).select("id").limit(1).execute()
107
+ self._tables_verified = True
108
+ except Exception:
109
+ self._tables_verified = False
110
+
111
+ def _ensure_supabase_tables(self):
112
+ """
113
+ Best-effort table check. Actual table creation should be done by running
114
+ supabase_analytics_tables.sql (mentioned in README + SUPABASE_SETUP).
115
+ """
116
+ self._quick_table_check()
117
+ if not self._tables_verified:
118
+ sql_file = (
119
+ Path(__file__).resolve().parents[3] / "supabase_analytics_tables.sql"
120
+ )
121
+ print("⚠️ Supabase analytics tables not verified.")
122
+ if sql_file.exists():
123
+ print(f" Run the SQL script: {sql_file}")
124
+ else:
125
+ print(" Missing supabase_analytics_tables.sql in repo root.")
126
+
127
  def _init_db(self):
128
  """Initialize database tables for analytics."""
129
  with sqlite3.connect(self.db_path) as conn:
130
  # Tool usage events table
131
+ conn.execute(
132
+ """
133
  CREATE TABLE IF NOT EXISTS tool_usage_events (
134
  id INTEGER PRIMARY KEY AUTOINCREMENT,
135
  tenant_id TEXT NOT NULL,
 
142
  error_message TEXT,
143
  metadata TEXT
144
  )
145
+ """
146
+ )
147
+
148
  # Red-flag violations table
149
+ conn.execute(
150
+ """
151
  CREATE TABLE IF NOT EXISTS redflag_violations (
152
  id INTEGER PRIMARY KEY AUTOINCREMENT,
153
  tenant_id TEXT NOT NULL,
 
160
  message_preview TEXT,
161
  timestamp INTEGER NOT NULL
162
  )
163
+ """
164
+ )
165
+
166
  # RAG search events with quality metrics
167
+ conn.execute(
168
+ """
169
  CREATE TABLE IF NOT EXISTS rag_search_events (
170
  id INTEGER PRIMARY KEY AUTOINCREMENT,
171
  tenant_id TEXT NOT NULL,
 
176
  timestamp INTEGER NOT NULL,
177
  latency_ms INTEGER
178
  )
179
+ """
180
+ )
181
+
182
  # Agent query events (overall query tracking)
183
+ conn.execute(
184
+ """
185
  CREATE TABLE IF NOT EXISTS agent_query_events (
186
  id INTEGER PRIMARY KEY AUTOINCREMENT,
187
  tenant_id TEXT NOT NULL,
 
194
  success BOOLEAN DEFAULT 1,
195
  timestamp INTEGER NOT NULL
196
  )
197
+ """
198
+ )
199
+
200
  # Create indexes separately (SQLite doesn't support inline INDEX in CREATE TABLE)
201
+ conn.execute(
202
+ """
203
  CREATE INDEX IF NOT EXISTS idx_tool_usage_tenant_timestamp
204
  ON tool_usage_events(tenant_id, timestamp)
205
+ """
206
+ )
207
+
208
+ conn.execute(
209
+ """
210
  CREATE INDEX IF NOT EXISTS idx_redflag_tenant_timestamp
211
  ON redflag_violations(tenant_id, timestamp)
212
+ """
213
+ )
214
+
215
+ conn.execute(
216
+ """
217
  CREATE INDEX IF NOT EXISTS idx_rag_search_tenant_timestamp
218
  ON rag_search_events(tenant_id, timestamp)
219
+ """
220
+ )
221
+
222
+ conn.execute(
223
+ """
224
  CREATE INDEX IF NOT EXISTS idx_agent_query_tenant_timestamp
225
  ON agent_query_events(tenant_id, timestamp)
226
+ """
227
+ )
228
+
229
  conn.commit()
230
 
231
+ # ------------------------------------------------------------------
232
+ # Logging helpers
233
+ # ------------------------------------------------------------------
234
+
235
+ @staticmethod
236
+ def _now_ts() -> int:
237
+ return int(time.time())
238
+
239
+ def _serialize_tools(self, tools_used: Optional[List[str]]) -> Optional[str]:
240
+ if tools_used:
241
+ return json.dumps(tools_used)
242
+ return None
243
+
244
+ def _serialize_metadata(self, metadata: Optional[Dict[str, Any]]):
245
+ if metadata:
246
+ return json.dumps(metadata)
247
+ return None
248
+
249
+ def _supabase_insert(self, table: str, payload: Dict[str, Any]):
250
+ if not self.supabase_client:
251
+ return
252
+ try:
253
+ self.supabase_client.table(table).insert(payload).execute()
254
+ except Exception as exc: # pragma: no cover - logging only
255
+ print(f"❌ Supabase insert failed for {table}: {exc}")
256
+
257
+ def _supabase_simple_select(
258
+ self,
259
+ table: str,
260
+ tenant_id: str,
261
+ since_timestamp: Optional[int] = None,
262
+ limit: Optional[int] = None,
263
+ order_desc: bool = False,
264
+ ) -> List[Dict[str, Any]]:
265
+ if not self.supabase_client:
266
+ return []
267
+ query = (
268
+ self.supabase_client.table(table)
269
+ .select("*")
270
+ .eq("tenant_id", tenant_id)
271
+ .order("timestamp", desc=order_desc)
272
+ )
273
+ if since_timestamp is not None:
274
+ query = query.gte("timestamp", since_timestamp)
275
+ if limit is not None:
276
+ query = query.limit(limit)
277
+ response = query.execute()
278
+ return response.data or []
279
+
280
+ def _supabase_fetch_all(
281
+ self,
282
+ table: str,
283
+ tenant_id: str,
284
+ since_timestamp: Optional[int] = None,
285
+ ) -> List[Dict[str, Any]]:
286
+ """Fetch all rows for a tenant (used for aggregations)."""
287
+ if not self.supabase_client:
288
+ return []
289
+
290
+ rows: List[Dict[str, Any]] = []
291
+ start = 0
292
+ batch_size = 1000
293
+
294
+ while True:
295
+ query = (
296
+ self.supabase_client.table(table)
297
+ .select("*")
298
+ .eq("tenant_id", tenant_id)
299
+ .order("timestamp", desc=False)
300
+ .range(start, start + batch_size - 1)
301
+ )
302
+ if since_timestamp is not None:
303
+ query = query.gte("timestamp", since_timestamp)
304
+ response = query.execute()
305
+ batch = response.data or []
306
+ rows.extend(batch)
307
+ if len(batch) < batch_size:
308
+ break
309
+ start += batch_size
310
+
311
+ return rows
312
+
313
  def log_tool_usage(
314
  self,
315
  tenant_id: str,
 
322
  user_id: Optional[str] = None
323
  ):
324
  """Log a tool usage event."""
325
+ if self.use_supabase and self.supabase_client:
326
+ payload = {
327
+ "tenant_id": tenant_id,
328
+ "user_id": user_id,
329
+ "tool_name": tool_name,
330
+ "timestamp": self._now_ts(),
331
+ "latency_ms": latency_ms,
332
+ "tokens_used": tokens_used,
333
+ "success": success,
334
+ "error_message": error_message,
335
+ "metadata": metadata,
336
+ }
337
+ self._supabase_insert(self.table_names["tool_usage"], payload)
338
+ return
339
+
340
  with sqlite3.connect(self.db_path) as conn:
341
+ conn.execute(
342
+ """
343
  INSERT INTO tool_usage_events
344
  (tenant_id, user_id, tool_name, timestamp, latency_ms, tokens_used, success, error_message, metadata)
345
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
346
+ """,
347
+ (
348
+ tenant_id,
349
+ user_id,
350
+ tool_name,
351
+ self._now_ts(),
352
+ latency_ms,
353
+ tokens_used,
354
+ 1 if success else 0,
355
+ error_message,
356
+ self._serialize_metadata(metadata),
357
+ ),
358
+ )
359
  conn.commit()
360
 
361
  def log_redflag_violation(
 
370
  user_id: Optional[str] = None
371
  ):
372
  """Log a red-flag violation."""
373
+ truncated_message = message_preview[:200] if message_preview else None
374
+
375
+ if self.use_supabase and self.supabase_client:
376
+ payload = {
377
+ "tenant_id": tenant_id,
378
+ "user_id": user_id,
379
+ "rule_id": rule_id,
380
+ "rule_pattern": rule_pattern,
381
+ "severity": severity,
382
+ "matched_text": matched_text,
383
+ "confidence": confidence,
384
+ "message_preview": truncated_message,
385
+ "timestamp": self._now_ts(),
386
+ }
387
+ self._supabase_insert(self.table_names["redflags"], payload)
388
+ return
389
+
390
  with sqlite3.connect(self.db_path) as conn:
391
+ conn.execute(
392
+ """
393
  INSERT INTO redflag_violations
394
  (tenant_id, user_id, rule_id, rule_pattern, severity, matched_text, confidence, message_preview, timestamp)
395
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
396
+ """,
397
+ (
398
+ tenant_id,
399
+ user_id,
400
+ rule_id,
401
+ rule_pattern,
402
+ severity,
403
+ matched_text,
404
+ confidence,
405
+ truncated_message,
406
+ self._now_ts(),
407
+ ),
408
+ )
409
  conn.commit()
410
 
411
  def log_rag_search(
 
418
  latency_ms: Optional[int] = None
419
  ):
420
  """Log a RAG search event with quality metrics."""
421
+ trimmed_query = query[:500]
422
+ if self.use_supabase and self.supabase_client:
423
+ payload = {
424
+ "tenant_id": tenant_id,
425
+ "query": trimmed_query,
426
+ "hits_count": hits_count,
427
+ "avg_score": avg_score,
428
+ "top_score": top_score,
429
+ "timestamp": self._now_ts(),
430
+ "latency_ms": latency_ms,
431
+ }
432
+ self._supabase_insert(self.table_names["rag_search"], payload)
433
+ return
434
+
435
  with sqlite3.connect(self.db_path) as conn:
436
+ conn.execute(
437
+ """
438
  INSERT INTO rag_search_events
439
  (tenant_id, query, hits_count, avg_score, top_score, timestamp, latency_ms)
440
  VALUES (?, ?, ?, ?, ?, ?, ?)
441
+ """,
442
+ (
443
+ tenant_id,
444
+ trimmed_query,
445
+ hits_count,
446
+ avg_score,
447
+ top_score,
448
+ self._now_ts(),
449
+ latency_ms,
450
+ ),
451
+ )
452
  conn.commit()
453
 
454
  def log_agent_query(
 
463
  user_id: Optional[str] = None
464
  ):
465
  """Log an agent query event (overall query tracking)."""
466
+ truncated_message = message_preview[:200]
467
+ serialized_tools = self._serialize_tools(tools_used)
468
+
469
+ if self.use_supabase and self.supabase_client:
470
+ payload = {
471
+ "tenant_id": tenant_id,
472
+ "user_id": user_id,
473
+ "message_preview": truncated_message,
474
+ "intent": intent,
475
+ "tools_used": tools_used,
476
+ "total_tokens": total_tokens,
477
+ "total_latency_ms": total_latency_ms,
478
+ "success": success,
479
+ "timestamp": self._now_ts(),
480
+ }
481
+ self._supabase_insert(self.table_names["agent_query"], payload)
482
+ return
483
+
484
  with sqlite3.connect(self.db_path) as conn:
485
+ conn.execute(
486
+ """
487
  INSERT INTO agent_query_events
488
  (tenant_id, user_id, message_preview, intent, tools_used, total_tokens, total_latency_ms, success, timestamp)
489
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
490
+ """,
491
+ (
492
+ tenant_id,
493
+ user_id,
494
+ truncated_message,
495
+ intent,
496
+ serialized_tools,
497
+ total_tokens,
498
+ total_latency_ms,
499
+ 1 if success else 0,
500
+ self._now_ts(),
501
+ ),
502
+ )
503
  conn.commit()
504
 
505
  def get_tool_usage_stats(
 
508
  since_timestamp: Optional[int] = None
509
  ) -> Dict[str, Any]:
510
  """Get tool usage statistics for a tenant."""
511
+ if self.use_supabase and self.supabase_client:
512
+ rows = self._supabase_fetch_all(
513
+ self.table_names["tool_usage"], tenant_id, since_timestamp
514
+ )
515
+ else:
516
+ with sqlite3.connect(self.db_path) as conn:
517
+ conn.row_factory = sqlite3.Row
518
+
519
+ query = """
520
+ SELECT
521
+ tool_name,
522
+ COUNT(*) as count,
523
+ AVG(latency_ms) as avg_latency_ms,
524
+ SUM(tokens_used) as total_tokens,
525
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count
526
+ FROM tool_usage_events
527
+ WHERE tenant_id = ?
528
+ """
529
+ params = [tenant_id]
530
+
531
+ if since_timestamp:
532
+ query += " AND timestamp >= ?"
533
+ params.append(since_timestamp)
534
+
535
+ query += " GROUP BY tool_name"
536
+
537
+ cursor = conn.execute(query, params)
538
+ rows = cursor.fetchall()
539
+
540
+ stats = {}
541
+ for row in rows:
542
+ tool_name = row["tool_name"]
543
+ stats[tool_name] = {
544
+ "count": row["count"],
545
+ "avg_latency_ms": round(row["avg_latency_ms"] or 0, 2),
546
+ "total_tokens": row["total_tokens"] or 0,
547
+ "success_count": row["success_count"],
548
+ "error_count": row["count"] - row["success_count"],
549
+ }
550
+
551
+ return stats
552
+
553
+ # Supabase aggregation (computed in Python)
554
+ stats: Dict[str, Dict[str, Any]] = {}
555
+ for row in rows:
556
+ tool_name = row.get("tool_name")
557
+ if not tool_name:
558
+ continue
559
+ entry = stats.setdefault(
560
+ tool_name,
561
+ {
562
+ "count": 0,
563
+ "avg_latency_ms": 0.0,
564
+ "total_tokens": 0,
565
+ "success_count": 0,
566
+ "error_count": 0,
567
+ },
568
+ )
569
+ entry["count"] += 1
570
+ latency = row.get("latency_ms") or 0
571
+ entry["avg_latency_ms"] += latency
572
+ entry["total_tokens"] += row.get("tokens_used") or 0
573
+ if row.get("success"):
574
+ entry["success_count"] += 1
575
+ else:
576
+ entry["error_count"] += 1
577
+
578
+ for tool_name, entry in stats.items():
579
+ if entry["count"]:
580
+ entry["avg_latency_ms"] = round(entry["avg_latency_ms"] / entry["count"], 2)
581
+
582
+ return stats
583
 
584
  def get_redflag_violations(
585
  self,
 
588
  since_timestamp: Optional[int] = None
589
  ) -> List[Dict[str, Any]]:
590
  """Get recent red-flag violations for a tenant."""
591
+ if self.use_supabase and self.supabase_client:
592
+ rows = self._supabase_simple_select(
593
+ self.table_names["redflags"],
594
+ tenant_id,
595
+ since_timestamp=since_timestamp,
596
+ limit=limit,
597
+ order_desc=True,
598
+ )
599
+ return rows
600
+
601
  with sqlite3.connect(self.db_path) as conn:
602
  conn.row_factory = sqlite3.Row
603
+
604
  query = """
605
  SELECT * FROM redflag_violations
606
  WHERE tenant_id = ?
607
  """
608
  params = [tenant_id]
609
+
610
  if since_timestamp:
611
  query += " AND timestamp >= ?"
612
  params.append(since_timestamp)
613
+
614
  query += " ORDER BY timestamp DESC LIMIT ?"
615
  params.append(limit)
616
+
617
  cursor = conn.execute(query, params)
618
  rows = cursor.fetchall()
619
+
620
  return [dict(row) for row in rows]
621
 
622
  def get_activity_summary(
 
625
  since_timestamp: Optional[int] = None
626
  ) -> Dict[str, Any]:
627
  """Get activity summary for a tenant."""
628
+ if self.use_supabase and self.supabase_client:
629
+ queries = self._supabase_fetch_all(
630
+ self.table_names["agent_query"], tenant_id, since_timestamp
631
+ )
632
+ redflags = self._supabase_fetch_all(
633
+ self.table_names["redflags"], tenant_id, since_timestamp
634
+ )
635
+
636
+ total_queries = len(queries)
637
+ active_users = len({row["user_id"] for row in queries if row.get("user_id")})
638
+ last_query_ts = max((row.get("timestamp") or 0) for row in queries) if queries else None
639
+ redflag_count = len(redflags)
640
+
641
+ return {
642
+ "total_queries": total_queries,
643
+ "active_users": active_users,
644
+ "redflag_count": redflag_count,
645
+ "last_query": datetime.fromtimestamp(last_query_ts).isoformat()
646
+ if last_query_ts
647
+ else None,
648
+ }
649
+
650
  with sqlite3.connect(self.db_path) as conn:
651
  conn.row_factory = sqlite3.Row
652
+
653
  # Total queries
654
  query = "SELECT COUNT(*) as total FROM agent_query_events WHERE tenant_id = ?"
655
  params = [tenant_id]
656
  if since_timestamp:
657
  query += " AND timestamp >= ?"
658
  params.append(since_timestamp)
659
+
660
  total_queries = conn.execute(query, params).fetchone()["total"]
661
+
662
  # Active users (unique user_ids in the period)
663
  query = """
664
  SELECT COUNT(DISTINCT user_id) as active_users
 
669
  if since_timestamp:
670
  query += " AND timestamp >= ?"
671
  params.append(since_timestamp)
672
+
673
  active_users = conn.execute(query, params).fetchone()["active_users"]
674
+
675
  # Last query timestamp
676
  query = """
677
  SELECT MAX(timestamp) as last_query
 
679
  WHERE tenant_id = ?
680
  """
681
  last_query_ts = conn.execute(query, [tenant_id]).fetchone()["last_query"]
682
+
683
  # Red-flag count
684
  query = "SELECT COUNT(*) as count FROM redflag_violations WHERE tenant_id = ?"
685
  params = [tenant_id]
686
  if since_timestamp:
687
  query += " AND timestamp >= ?"
688
  params.append(since_timestamp)
689
+
690
  redflag_count = conn.execute(query, params).fetchone()["count"]
691
+
692
  return {
693
  "total_queries": total_queries,
694
  "active_users": active_users or 0,
695
  "redflag_count": redflag_count,
696
+ "last_query": datetime.fromtimestamp(last_query_ts).isoformat()
697
+ if last_query_ts
698
+ else None,
699
  }
700
 
701
  def get_rag_quality_metrics(
 
704
  since_timestamp: Optional[int] = None
705
  ) -> Dict[str, Any]:
706
  """Get RAG quality metrics (recall/precision indicators)."""
707
+ if self.use_supabase and self.supabase_client:
708
+ rows = self._supabase_fetch_all(
709
+ self.table_names["rag_search"], tenant_id, since_timestamp
710
+ )
711
+
712
+ if not rows:
713
+ return {
714
+ "total_searches": 0,
715
+ "avg_hits_per_search": 0,
716
+ "avg_score": 0,
717
+ "avg_top_score": 0,
718
+ "avg_latency_ms": 0,
719
+ }
720
+
721
+ total_searches = len(rows)
722
+ avg_hits = sum(row.get("hits_count") or 0 for row in rows) / total_searches
723
+ avg_avg_score = sum(row.get("avg_score") or 0 for row in rows) / total_searches
724
+ avg_top_score = sum(row.get("top_score") or 0 for row in rows) / total_searches
725
+ avg_latency = sum(row.get("latency_ms") or 0 for row in rows) / total_searches
726
+
727
+ return {
728
+ "total_searches": total_searches,
729
+ "avg_hits_per_search": round(avg_hits, 2),
730
+ "avg_score": round(avg_avg_score, 3),
731
+ "avg_top_score": round(avg_top_score, 3),
732
+ "avg_latency_ms": round(avg_latency, 2),
733
+ }
734
+
735
  with sqlite3.connect(self.db_path) as conn:
736
  conn.row_factory = sqlite3.Row
737
+
738
  query = """
739
  SELECT
740
  COUNT(*) as total_searches,
 
746
  WHERE tenant_id = ?
747
  """
748
  params = [tenant_id]
749
+
750
  if since_timestamp:
751
  query += " AND timestamp >= ?"
752
  params.append(since_timestamp)
753
+
754
  row = conn.execute(query, params).fetchone()
755
+
756
  return {
757
  "total_searches": row["total_searches"] or 0,
758
  "avg_hits_per_search": round(row["avg_hits"] or 0, 2),
759
  "avg_score": round(row["avg_avg_score"] or 0, 3),
760
  "avg_top_score": round(row["avg_top_score"] or 0, 3),
761
+ "avg_latency_ms": round(row["avg_latency_ms"] or 0, 2),
762
  }
763
 
check_supabase_rules.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Quick script to verify Supabase rules storage is working.
4
+ Run this to check if rules are being saved to Supabase.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ # Load environment variables from .env file
12
+ from dotenv import load_dotenv
13
+ load_dotenv()
14
+
15
+ # Add backend to path
16
+ backend_dir = Path(__file__).resolve().parent
17
+ sys.path.insert(0, str(backend_dir))
18
+
19
+ from backend.api.storage.rules_store import RulesStore
20
+
21
+
22
+ def main():
23
+ print("=" * 60)
24
+ print("Supabase Rules Storage Verification")
25
+ print("=" * 60)
26
+
27
+ # Check environment variables
28
+ supabase_url = os.getenv("SUPABASE_URL")
29
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
30
+
31
+ print("\n1. Checking Environment Variables:")
32
+ if supabase_url:
33
+ print(f" βœ… SUPABASE_URL is set: {supabase_url[:50]}...")
34
+ else:
35
+ print(" ❌ SUPABASE_URL is not set")
36
+ print(" Add it to your .env file: SUPABASE_URL=https://your-project.supabase.co")
37
+
38
+ if supabase_key:
39
+ print(f" βœ… SUPABASE_SERVICE_KEY is set: {supabase_key[:20]}...")
40
+ else:
41
+ print(" ❌ SUPABASE_SERVICE_KEY is not set")
42
+ print(" Add it to your .env file: SUPABASE_SERVICE_KEY=your_service_role_key")
43
+
44
+ if not supabase_url or not supabase_key:
45
+ print("\n⚠️ Supabase credentials are missing!")
46
+ print(" Rules will be saved to SQLite instead.")
47
+ print(" See SUPABASE_SETUP.md for setup instructions.")
48
+ print("\n To use Supabase:")
49
+ print(" 1. Add SUPABASE_URL and SUPABASE_SERVICE_KEY to your .env file")
50
+ print(" 2. Create the admin_rules table in Supabase (see supabase_admin_rules_table.sql)")
51
+ print(" 3. Restart your application")
52
+ return
53
+
54
+ # Initialize RulesStore
55
+ print("\n2. Initializing RulesStore:")
56
+ try:
57
+ store = RulesStore(auto_create_table=True)
58
+ print(f" βœ… RulesStore initialized")
59
+ print(f" πŸ“¦ Using Supabase: {store.use_supabase}")
60
+
61
+ if not store.use_supabase:
62
+ print(" ⚠️ RulesStore is using SQLite, not Supabase!")
63
+ print(" Check that:")
64
+ print(" - SUPABASE_URL and SUPABASE_SERVICE_KEY are correct")
65
+ print(" - Supabase Python client is installed: pip install supabase")
66
+ return
67
+
68
+ except Exception as e:
69
+ print(f" ❌ Failed to initialize RulesStore: {e}")
70
+ return
71
+
72
+ # Test adding a rule
73
+ print("\n3. Testing Rule Storage:")
74
+ test_tenant = "test_verification"
75
+ test_rule = "Test rule for Supabase verification"
76
+
77
+ try:
78
+ # Delete test rule if it exists
79
+ store.delete_rule(test_tenant, test_rule)
80
+
81
+ # Add test rule
82
+ success = store.add_rule(
83
+ test_tenant,
84
+ test_rule,
85
+ severity="medium",
86
+ description="Verification test rule"
87
+ )
88
+
89
+ if success:
90
+ print(f" βœ… Successfully added test rule to Supabase")
91
+ else:
92
+ print(f" ❌ Failed to add rule to Supabase")
93
+ return
94
+
95
+ # Retrieve rule
96
+ rules = store.get_rules(test_tenant)
97
+ if test_rule in rules:
98
+ print(f" βœ… Successfully retrieved rule from Supabase")
99
+ print(f" πŸ“‹ Found {len(rules)} rule(s) for tenant '{test_tenant}'")
100
+ else:
101
+ print(f" ❌ Rule not found after adding")
102
+ return
103
+
104
+ # Get detailed rules
105
+ detailed_rules = store.get_rules_detailed(test_tenant)
106
+ if detailed_rules:
107
+ print(f" βœ… Successfully retrieved detailed rules")
108
+ for rule in detailed_rules:
109
+ if rule['rule'] == test_rule:
110
+ print(f" πŸ“ Rule details:")
111
+ print(f" - Pattern: {rule.get('pattern', 'N/A')}")
112
+ print(f" - Severity: {rule.get('severity', 'N/A')}")
113
+ print(f" - Enabled: {rule.get('enabled', 'N/A')}")
114
+
115
+ # Cleanup test rule
116
+ store.delete_rule(test_tenant, test_rule)
117
+ print(f" 🧹 Cleaned up test rule")
118
+
119
+ except Exception as e:
120
+ print(f" ❌ Error during test: {e}")
121
+ import traceback
122
+ traceback.print_exc()
123
+ return
124
+
125
+ print("\n" + "=" * 60)
126
+ print("βœ… All checks passed! Rules are being saved to Supabase.")
127
+ print("=" * 60)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
132
+
migrate_sqlite_to_supabase.py ADDED
@@ -0,0 +1,826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ One-shot migration script that copies existing SQLite data (admin rules + analytics)
4
+ into Supabase tables. Run this after creating the Supabase schemas:
5
+
6
+ 1. supabase_admin_rules_table.sql
7
+ 2. supabase_analytics_tables.sql
8
+
9
+ Usage:
10
+ python migrate_sqlite_to_supabase.py [--force]
11
+
12
+ Connection Methods (choose one):
13
+ - POSTGRESQL_URL (recommended): Direct PostgreSQL connection
14
+ Format: postgresql://user:password@host:port/database
15
+ - SUPABASE_URL + SUPABASE_SERVICE_KEY: Supabase REST API
16
+ Requires: SUPABASE_URL and SUPABASE_SERVICE_KEY in your .env
17
+
18
+ Notes:
19
+ - Does not delete local SQLite data
20
+ - Re-running without --force will skip tables that already contain Supabase rows
21
+ - POSTGRESQL_URL method is faster and doesn't require service_role key
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import socket
30
+ import sqlite3
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any, Dict, Iterable, List
34
+
35
+ from dotenv import load_dotenv
36
+
37
+ # Try to import both connection methods
38
+ try:
39
+ from supabase import Client, create_client
40
+ SUPABASE_AVAILABLE = True
41
+ except ImportError:
42
+ SUPABASE_AVAILABLE = False
43
+ Client = None
44
+
45
+ try:
46
+ import psycopg2
47
+ from psycopg2.extras import execute_batch
48
+ PSYCOPG2_AVAILABLE = True
49
+ except ImportError:
50
+ PSYCOPG2_AVAILABLE = False
51
+
52
+ BATCH_SIZE = 500
53
+
54
+
55
+ def chunked(items: List[Dict[str, Any]], size: int) -> Iterable[List[Dict[str, Any]]]:
56
+ for i in range(0, len(items), size):
57
+ yield items[i : i + size]
58
+
59
+
60
+ def get_connection_method():
61
+ """Determine which connection method to use: PostgreSQL direct or Supabase API."""
62
+ load_dotenv()
63
+ postgres_url = os.getenv("POSTGRESQL_URL")
64
+ supabase_url = os.getenv("SUPABASE_URL")
65
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
66
+
67
+ # Prefer PostgreSQL direct connection if available
68
+ if postgres_url and PSYCOPG2_AVAILABLE:
69
+ return "postgresql"
70
+ elif supabase_url and supabase_key and SUPABASE_AVAILABLE:
71
+ return "supabase"
72
+ else:
73
+ return None
74
+
75
+
76
+ def get_postgres_connection():
77
+ """Get direct PostgreSQL connection using POSTGRESQL_URL."""
78
+ load_dotenv()
79
+ postgres_url = os.getenv("POSTGRESQL_URL")
80
+
81
+ if not postgres_url:
82
+ raise RuntimeError(
83
+ "POSTGRESQL_URL not set in .env file.\n"
84
+ " Format: postgresql://user:password@host:port/database"
85
+ )
86
+
87
+ if not PSYCOPG2_AVAILABLE:
88
+ raise RuntimeError(
89
+ "psycopg2 not installed. Install it with: pip install psycopg2-binary"
90
+ )
91
+
92
+ try:
93
+ conn = psycopg2.connect(postgres_url)
94
+ return conn
95
+ except Exception as e:
96
+ raise RuntimeError(
97
+ f"Failed to connect to PostgreSQL: {e}\n"
98
+ " Check that POSTGRESQL_URL is correct and the database is accessible."
99
+ ) from e
100
+
101
+
102
+ def load_supabase_client() -> Client:
103
+ load_dotenv()
104
+ url = os.getenv("SUPABASE_URL")
105
+ key = os.getenv("SUPABASE_SERVICE_KEY")
106
+
107
+ if not url or not key:
108
+ raise RuntimeError(
109
+ "Supabase credentials missing. Set SUPABASE_URL and SUPABASE_SERVICE_KEY in your .env file.\n"
110
+ f" SUPABASE_URL: {'βœ… Set' if url else '❌ Missing'}\n"
111
+ f" SUPABASE_SERVICE_KEY: {'βœ… Set' if key else '❌ Missing'}"
112
+ )
113
+
114
+ # Validate URL format
115
+ if not url.startswith("https://"):
116
+ raise RuntimeError(
117
+ f"Invalid SUPABASE_URL format. Expected https://... but got: {url[:50]}...\n"
118
+ " Example: https://your-project-id.supabase.co"
119
+ )
120
+
121
+ if ".supabase.co" not in url:
122
+ print(f"⚠️ Warning: SUPABASE_URL doesn't contain '.supabase.co': {url[:50]}...")
123
+ print(" Make sure this is the correct Supabase project URL.")
124
+
125
+ # Validate and clean API key format
126
+ key_trimmed = key.strip()
127
+ if key_trimmed != key:
128
+ print("⚠️ Warning: SUPABASE_SERVICE_KEY has leading/trailing whitespace. Trimming...")
129
+ key = key_trimmed # Use trimmed version
130
+
131
+ if not key.startswith("eyJ"):
132
+ print("⚠️ Warning: SUPABASE_SERVICE_KEY doesn't start with 'eyJ' (expected JWT format)")
133
+ print(" Make sure you're using the service_role key, not the anon key.")
134
+
135
+ if len(key) < 100:
136
+ print("⚠️ Warning: SUPABASE_SERVICE_KEY seems too short (should be ~200+ characters)")
137
+ print(" Make sure you copied the entire key from Supabase Dashboard.")
138
+
139
+ # Mask URL and key for display
140
+ masked_url = url[:20] + "..." + url[-15:] if len(url) > 35 else url[:20] + "..."
141
+ masked_key = key[:10] + "..." + key[-10:] if len(key) > 20 else key[:10] + "..."
142
+ print(f"πŸ”— Connecting to Supabase: {masked_url}")
143
+ print(f"πŸ”‘ Using API key: {masked_key} ({len(key)} chars)")
144
+
145
+ # Test DNS resolution first
146
+ try:
147
+ hostname = url.replace("https://", "").replace("http://", "").split("/")[0]
148
+ print(f" Resolving DNS for: {hostname}...")
149
+ socket.gethostbyname(hostname)
150
+ print(" βœ… DNS resolution successful")
151
+ except socket.gaierror as dns_err:
152
+ raise RuntimeError(
153
+ f"❌ Cannot resolve DNS for Supabase URL: {url}\n"
154
+ " This usually means:\n"
155
+ " 1. The Supabase project doesn't exist or was deleted\n"
156
+ " 2. The project is paused (check Supabase Dashboard)\n"
157
+ " 3. The URL is incorrect\n"
158
+ " 4. Network/DNS connectivity issue\n\n"
159
+ " To fix:\n"
160
+ " 1. Go to https://app.supabase.com and check your project status\n"
161
+ " 2. If paused, resume it from the dashboard\n"
162
+ " 3. Copy the correct URL from Settings β†’ API\n"
163
+ f" DNS Error: {dns_err}"
164
+ ) from dns_err
165
+
166
+ try:
167
+ client = create_client(url, key)
168
+ # Test connection with a simple query
169
+ print(" Testing connection...")
170
+ client.table("admin_rules").select("id").limit(0).execute()
171
+ print(" βœ… Connection successful!")
172
+ return client
173
+ except Exception as e:
174
+ error_msg = str(e)
175
+ if "getaddrinfo failed" in error_msg or "ConnectError" in str(type(e)):
176
+ raise RuntimeError(
177
+ f"❌ Cannot connect to Supabase URL: {url}\n"
178
+ " Possible issues:\n"
179
+ " 1. URL is incomplete or incorrect (should be: https://xxxxx.supabase.co)\n"
180
+ " 2. Network connectivity problem\n"
181
+ " 3. Supabase project doesn't exist or is paused\n"
182
+ " 4. Firewall/proxy blocking the connection\n\n"
183
+ " Check your Supabase project at: https://app.supabase.com\n"
184
+ f" Error: {error_msg}"
185
+ ) from e
186
+ else:
187
+ # Check if it's an API key error
188
+ if "Invalid API key" in error_msg or "401" in error_msg:
189
+ raise RuntimeError(
190
+ f"❌ Invalid API Key Error\n"
191
+ " The SUPABASE_SERVICE_KEY in your .env file is incorrect.\n\n"
192
+ " To fix:\n"
193
+ " 1. Go to https://app.supabase.com β†’ Your Project β†’ Settings β†’ API\n"
194
+ " 2. Find the 'service_role' key (NOT the 'anon' key)\n"
195
+ " 3. Click 'Reveal' to show the full key\n"
196
+ " 4. Copy the ENTIRE key (it's very long, ~200+ characters)\n"
197
+ " 5. Update SUPABASE_SERVICE_KEY in your .env file\n"
198
+ " 6. Make sure there are NO quotes, spaces, or line breaks\n\n"
199
+ f" Current key length: {len(key)} characters\n"
200
+ f" Expected: ~200+ characters (JWT token starting with 'eyJ')\n\n"
201
+ f" Error details: {error_msg}"
202
+ ) from e
203
+ else:
204
+ raise RuntimeError(
205
+ f"❌ Failed to connect to Supabase: {error_msg}\n"
206
+ " Check that:\n"
207
+ " 1. SUPABASE_URL is correct and complete\n"
208
+ " 2. SUPABASE_SERVICE_KEY is the service_role key (not anon key)\n"
209
+ " 3. Your Supabase project is active (not paused)\n"
210
+ " 4. The tables exist (run supabase_admin_rules_table.sql and supabase_analytics_tables.sql)"
211
+ ) from e
212
+
213
+
214
+ def sqlite_rows(db_path: Path, query: str) -> List[Dict[str, Any]]:
215
+ conn = sqlite3.connect(db_path)
216
+ conn.row_factory = sqlite3.Row
217
+ rows = conn.execute(query).fetchall()
218
+ conn.close()
219
+ return [dict(row) for row in rows]
220
+
221
+
222
+ def warn_if_table_has_rows(client: Client, table: str) -> bool:
223
+ response = client.table(table).select("id", count="exact").limit(1).execute()
224
+ count = getattr(response, "count", None)
225
+ return bool(count and count > 0)
226
+
227
+
228
+ def iso_from_unix(ts: Any) -> str | None:
229
+ if ts is None:
230
+ return None
231
+ try:
232
+ return datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat()
233
+ except (ValueError, TypeError):
234
+ return None
235
+
236
+
237
+ def migrate_rules(client: Client, db_path: Path, force: bool):
238
+ table = "admin_rules"
239
+ if not force and warn_if_table_has_rows(client, table):
240
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
241
+ return
242
+
243
+ if not db_path.exists():
244
+ print(f"ℹ️ No local rules database found at {db_path}, skipping rules migration.")
245
+ return
246
+
247
+ rows = sqlite_rows(
248
+ db_path,
249
+ """
250
+ SELECT tenant_id, rule, pattern, severity, description, enabled, created_at
251
+ FROM admin_rules
252
+ """,
253
+ )
254
+ if not rows:
255
+ print("ℹ️ No rules to migrate.")
256
+ return
257
+
258
+ payload = []
259
+ for row in rows:
260
+ payload.append(
261
+ {
262
+ "tenant_id": row["tenant_id"],
263
+ "rule": row["rule"],
264
+ "pattern": row["pattern"] or row["rule"],
265
+ "severity": row.get("severity") or "medium",
266
+ "description": row.get("description") or row["rule"],
267
+ "enabled": bool(row.get("enabled", 1)),
268
+ "created_at": iso_from_unix(row.get("created_at")) or None,
269
+ }
270
+ )
271
+
272
+ for batch in chunked(payload, BATCH_SIZE):
273
+ client.table(table).upsert(batch, on_conflict="tenant_id,rule").execute()
274
+
275
+ print(f"βœ… Migrated {len(payload)} admin rule(s) to Supabase.")
276
+
277
+
278
+ def migrate_tool_usage(client: Client, db_path: Path, force: bool):
279
+ table = "tool_usage_events"
280
+ if not force and warn_if_table_has_rows(client, table):
281
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
282
+ return
283
+
284
+ rows = sqlite_rows(db_path, "SELECT * FROM tool_usage_events")
285
+ if not rows:
286
+ print("ℹ️ No tool usage events to migrate.")
287
+ return
288
+
289
+ payload = []
290
+ for row in rows:
291
+ metadata = row.get("metadata")
292
+ payload.append(
293
+ {
294
+ "tenant_id": row["tenant_id"],
295
+ "user_id": row.get("user_id"),
296
+ "tool_name": row["tool_name"],
297
+ "timestamp": row["timestamp"],
298
+ "latency_ms": row.get("latency_ms"),
299
+ "tokens_used": row.get("tokens_used"),
300
+ "success": bool(row.get("success", 1)),
301
+ "error_message": row.get("error_message"),
302
+ "metadata": json.loads(metadata) if metadata else None,
303
+ }
304
+ )
305
+
306
+ for batch in chunked(payload, BATCH_SIZE):
307
+ client.table(table).insert(batch).execute()
308
+
309
+ print(f"βœ… Migrated {len(payload)} tool usage event(s).")
310
+
311
+
312
+ def migrate_redflags(client: Client, db_path: Path, force: bool):
313
+ table = "redflag_violations"
314
+ if not force and warn_if_table_has_rows(client, table):
315
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
316
+ return
317
+
318
+ rows = sqlite_rows(db_path, "SELECT * FROM redflag_violations")
319
+ if not rows:
320
+ print("ℹ️ No red-flag violations to migrate.")
321
+ return
322
+
323
+ payload = []
324
+ for row in rows:
325
+ payload.append(
326
+ {
327
+ "tenant_id": row["tenant_id"],
328
+ "user_id": row.get("user_id"),
329
+ "rule_id": row["rule_id"],
330
+ "rule_pattern": row.get("rule_pattern"),
331
+ "severity": row["severity"],
332
+ "matched_text": row.get("matched_text"),
333
+ "confidence": row.get("confidence"),
334
+ "message_preview": row.get("message_preview"),
335
+ "timestamp": row["timestamp"],
336
+ }
337
+ )
338
+
339
+ for batch in chunked(payload, BATCH_SIZE):
340
+ client.table(table).insert(batch).execute()
341
+
342
+ print(f"βœ… Migrated {len(payload)} red-flag violation(s).")
343
+
344
+
345
+ def migrate_rag_searches(client: Client, db_path: Path, force: bool):
346
+ table = "rag_search_events"
347
+ if not force and warn_if_table_has_rows(client, table):
348
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
349
+ return
350
+
351
+ rows = sqlite_rows(db_path, "SELECT * FROM rag_search_events")
352
+ if not rows:
353
+ print("ℹ️ No RAG search events to migrate.")
354
+ return
355
+
356
+ payload = []
357
+ for row in rows:
358
+ payload.append(
359
+ {
360
+ "tenant_id": row["tenant_id"],
361
+ "query": row["query"],
362
+ "hits_count": row.get("hits_count"),
363
+ "avg_score": row.get("avg_score"),
364
+ "top_score": row.get("top_score"),
365
+ "timestamp": row["timestamp"],
366
+ "latency_ms": row.get("latency_ms"),
367
+ }
368
+ )
369
+
370
+ for batch in chunked(payload, BATCH_SIZE):
371
+ client.table(table).insert(batch).execute()
372
+
373
+ print(f"βœ… Migrated {len(payload)} RAG search event(s).")
374
+
375
+
376
+ def migrate_agent_queries(client: Client, db_path: Path, force: bool):
377
+ table = "agent_query_events"
378
+ if not force and warn_if_table_has_rows(client, table):
379
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
380
+ return
381
+
382
+ rows = sqlite_rows(db_path, "SELECT * FROM agent_query_events")
383
+ if not rows:
384
+ print("ℹ️ No agent query events to migrate.")
385
+ return
386
+
387
+ payload = []
388
+ for row in rows:
389
+ tools = row.get("tools_used")
390
+ payload.append(
391
+ {
392
+ "tenant_id": row["tenant_id"],
393
+ "user_id": row.get("user_id"),
394
+ "message_preview": row.get("message_preview"),
395
+ "intent": row.get("intent"),
396
+ "tools_used": json.loads(tools) if tools else None,
397
+ "total_tokens": row.get("total_tokens"),
398
+ "total_latency_ms": row.get("total_latency_ms"),
399
+ "success": bool(row.get("success", 1)),
400
+ "timestamp": row["timestamp"],
401
+ }
402
+ )
403
+
404
+ for batch in chunked(payload, BATCH_SIZE):
405
+ client.table(table).insert(batch).execute()
406
+
407
+ print(f"βœ… Migrated {len(payload)} agent query event(s).")
408
+
409
+
410
+ def migrate_rules_postgres(conn, db_path: Path, force: bool, check_table_func):
411
+ """Migrate rules using PostgreSQL direct connection."""
412
+ table = "admin_rules"
413
+
414
+ # Check if table exists
415
+ if not table_exists_postgres(conn, table):
416
+ print(f"❌ Table '{table}' does not exist in PostgreSQL!")
417
+ print(f" Please create it first by running 'supabase_admin_rules_table.sql' in Supabase SQL Editor")
418
+ print(f" Skipping rules migration.")
419
+ return
420
+
421
+ if not force and check_table_func(table):
422
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
423
+ return
424
+
425
+ if not db_path.exists():
426
+ print(f"ℹ️ No local rules database found at {db_path}, skipping rules migration.")
427
+ return
428
+
429
+ rows = sqlite_rows(
430
+ db_path,
431
+ """
432
+ SELECT tenant_id, rule, pattern, severity, description, enabled, created_at
433
+ FROM admin_rules
434
+ """,
435
+ )
436
+ if not rows:
437
+ print("ℹ️ No rules to migrate.")
438
+ return
439
+
440
+ payload = []
441
+ for row in rows:
442
+ payload.append({
443
+ "tenant_id": row["tenant_id"],
444
+ "rule": row["rule"],
445
+ "pattern": row["pattern"] or row["rule"],
446
+ "severity": row.get("severity") or "medium",
447
+ "description": row.get("description") or row["rule"],
448
+ "enabled": bool(row.get("enabled", 1)),
449
+ "created_at": iso_from_unix(row.get("created_at")) or None,
450
+ })
451
+
452
+ columns = ["tenant_id", "rule", "pattern", "severity", "description", "enabled", "created_at"]
453
+ for batch in chunked(payload, BATCH_SIZE):
454
+ insert_batch_postgres(conn, table, columns, batch, on_conflict="tenant_id,rule")
455
+
456
+ print(f"βœ… Migrated {len(payload)} admin rule(s) to Supabase.")
457
+
458
+
459
+ def migrate_tool_usage_postgres(conn, db_path: Path, force: bool, check_table_func):
460
+ """Migrate tool usage events using PostgreSQL direct connection."""
461
+ table = "tool_usage_events"
462
+
463
+ if not table_exists_postgres(conn, table):
464
+ print(f"❌ Table '{table}' does not exist in PostgreSQL!")
465
+ print(f" Please create it first by running 'supabase_analytics_tables.sql' in Supabase SQL Editor")
466
+ print(f" Skipping tool usage migration.")
467
+ return
468
+
469
+ if not force and check_table_func(table):
470
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
471
+ return
472
+
473
+ rows = sqlite_rows(db_path, "SELECT * FROM tool_usage_events")
474
+ if not rows:
475
+ print("ℹ️ No tool usage events to migrate.")
476
+ return
477
+
478
+ payload = []
479
+ for row in rows:
480
+ metadata = row.get("metadata")
481
+ payload.append({
482
+ "tenant_id": row["tenant_id"],
483
+ "user_id": row.get("user_id"),
484
+ "tool_name": row["tool_name"],
485
+ "timestamp": row["timestamp"],
486
+ "latency_ms": row.get("latency_ms"),
487
+ "tokens_used": row.get("tokens_used"),
488
+ "success": bool(row.get("success", 1)),
489
+ "error_message": row.get("error_message"),
490
+ "metadata": json.loads(metadata) if metadata else None,
491
+ })
492
+
493
+ columns = ["tenant_id", "user_id", "tool_name", "timestamp", "latency_ms", "tokens_used", "success", "error_message", "metadata"]
494
+ for batch in chunked(payload, BATCH_SIZE):
495
+ insert_batch_postgres(conn, table, columns, batch)
496
+
497
+ print(f"βœ… Migrated {len(payload)} tool usage event(s).")
498
+
499
+
500
+ def migrate_redflags_postgres(conn, db_path: Path, force: bool, check_table_func):
501
+ """Migrate redflag violations using PostgreSQL direct connection."""
502
+ table = "redflag_violations"
503
+
504
+ if not table_exists_postgres(conn, table):
505
+ print(f"❌ Table '{table}' does not exist in PostgreSQL!")
506
+ print(f" Please create it first by running 'supabase_analytics_tables.sql' in Supabase SQL Editor")
507
+ print(f" Skipping redflag migration.")
508
+ return
509
+
510
+ if not force and check_table_func(table):
511
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
512
+ return
513
+
514
+ rows = sqlite_rows(db_path, "SELECT * FROM redflag_violations")
515
+ if not rows:
516
+ print("ℹ️ No red-flag violations to migrate.")
517
+ return
518
+
519
+ payload = []
520
+ for row in rows:
521
+ payload.append({
522
+ "tenant_id": row["tenant_id"],
523
+ "user_id": row.get("user_id"),
524
+ "rule_id": row["rule_id"],
525
+ "rule_pattern": row.get("rule_pattern"),
526
+ "severity": row["severity"],
527
+ "matched_text": row.get("matched_text"),
528
+ "confidence": row.get("confidence"),
529
+ "message_preview": row.get("message_preview"),
530
+ "timestamp": row["timestamp"],
531
+ })
532
+
533
+ columns = ["tenant_id", "user_id", "rule_id", "rule_pattern", "severity", "matched_text", "confidence", "message_preview", "timestamp"]
534
+ for batch in chunked(payload, BATCH_SIZE):
535
+ insert_batch_postgres(conn, table, columns, batch)
536
+
537
+ print(f"βœ… Migrated {len(payload)} red-flag violation(s).")
538
+
539
+
540
+ def migrate_rag_searches_postgres(conn, db_path: Path, force: bool, check_table_func):
541
+ """Migrate RAG search events using PostgreSQL direct connection."""
542
+ table = "rag_search_events"
543
+
544
+ if not table_exists_postgres(conn, table):
545
+ print(f"❌ Table '{table}' does not exist in PostgreSQL!")
546
+ print(f" Please create it first by running 'supabase_analytics_tables.sql' in Supabase SQL Editor")
547
+ print(f" Skipping RAG search migration.")
548
+ return
549
+
550
+ if not force and check_table_func(table):
551
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
552
+ return
553
+
554
+ rows = sqlite_rows(db_path, "SELECT * FROM rag_search_events")
555
+ if not rows:
556
+ print("ℹ️ No RAG search events to migrate.")
557
+ return
558
+
559
+ payload = []
560
+ for row in rows:
561
+ payload.append({
562
+ "tenant_id": row["tenant_id"],
563
+ "query": row["query"],
564
+ "hits_count": row.get("hits_count"),
565
+ "avg_score": row.get("avg_score"),
566
+ "top_score": row.get("top_score"),
567
+ "timestamp": row["timestamp"],
568
+ "latency_ms": row.get("latency_ms"),
569
+ })
570
+
571
+ columns = ["tenant_id", "query", "hits_count", "avg_score", "top_score", "timestamp", "latency_ms"]
572
+ for batch in chunked(payload, BATCH_SIZE):
573
+ insert_batch_postgres(conn, table, columns, batch)
574
+
575
+ print(f"βœ… Migrated {len(payload)} RAG search event(s).")
576
+
577
+
578
+ def migrate_agent_queries_postgres(conn, db_path: Path, force: bool, check_table_func):
579
+ """Migrate agent query events using PostgreSQL direct connection."""
580
+ table = "agent_query_events"
581
+
582
+ if not table_exists_postgres(conn, table):
583
+ print(f"❌ Table '{table}' does not exist in PostgreSQL!")
584
+ print(f" Please create it first by running 'supabase_analytics_tables.sql' in Supabase SQL Editor")
585
+ print(f" Skipping agent query migration.")
586
+ return
587
+
588
+ if not force and check_table_func(table):
589
+ print(f"⚠️ Supabase table '{table}' already has rows. Skipping (use --force to override).")
590
+ return
591
+
592
+ rows = sqlite_rows(db_path, "SELECT * FROM agent_query_events")
593
+ if not rows:
594
+ print("ℹ️ No agent query events to migrate.")
595
+ return
596
+
597
+ payload = []
598
+ for row in rows:
599
+ tools = row.get("tools_used")
600
+ payload.append({
601
+ "tenant_id": row["tenant_id"],
602
+ "user_id": row.get("user_id"),
603
+ "message_preview": row.get("message_preview"),
604
+ "intent": row.get("intent"),
605
+ "tools_used": json.loads(tools) if tools else None,
606
+ "total_tokens": row.get("total_tokens"),
607
+ "total_latency_ms": row.get("total_latency_ms"),
608
+ "success": bool(row.get("success", 1)),
609
+ "timestamp": row["timestamp"],
610
+ })
611
+
612
+ columns = ["tenant_id", "user_id", "message_preview", "intent", "tools_used", "total_tokens", "total_latency_ms", "success", "timestamp"]
613
+ for batch in chunked(payload, BATCH_SIZE):
614
+ insert_batch_postgres(conn, table, columns, batch)
615
+
616
+ print(f"βœ… Migrated {len(payload)} agent query event(s).")
617
+
618
+
619
+ def table_exists_postgres(conn, table: str) -> bool:
620
+ """Check if PostgreSQL table exists."""
621
+ with conn.cursor() as cur:
622
+ cur.execute("""
623
+ SELECT EXISTS (
624
+ SELECT FROM information_schema.tables
625
+ WHERE table_schema = 'public'
626
+ AND table_name = %s
627
+ )
628
+ """, (table,))
629
+ return cur.fetchone()[0]
630
+
631
+
632
+ def check_table_has_rows_postgres(conn, table: str) -> bool:
633
+ """Check if PostgreSQL table has rows."""
634
+ if not table_exists_postgres(conn, table):
635
+ return False
636
+ try:
637
+ with conn.cursor() as cur:
638
+ cur.execute(f"SELECT COUNT(*) FROM {table}")
639
+ count = cur.fetchone()[0]
640
+ return count > 0
641
+ except Exception as e:
642
+ error_str = str(e)
643
+ if "does not exist" in error_str or "relation" in error_str.lower():
644
+ return False
645
+ raise
646
+
647
+
648
+ def check_table_has_rows_supabase(client: Client, table: str) -> bool:
649
+ """Check if Supabase table has rows."""
650
+ try:
651
+ response = client.table(table).select("id", count="exact").limit(1).execute()
652
+ count = getattr(response, "count", None)
653
+ return bool(count and count > 0)
654
+ except:
655
+ return False
656
+
657
+
658
+ def insert_batch_postgres(conn, table: str, columns: List[str], batch: List[Dict[str, Any]], on_conflict: str = None):
659
+ """Insert batch into PostgreSQL table."""
660
+ if not batch:
661
+ return
662
+
663
+ placeholders = ", ".join(["%s"] * len(columns))
664
+ cols = ", ".join(columns)
665
+
666
+ if on_conflict:
667
+ # For admin_rules with unique constraint
668
+ update_cols = ", ".join([f"{col} = EXCLUDED.{col}" for col in columns if col != "id"])
669
+ query = f"INSERT INTO {table} ({cols}) VALUES ({placeholders}) ON CONFLICT ({on_conflict}) DO UPDATE SET {update_cols}"
670
+ else:
671
+ query = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
672
+
673
+ # Prepare values, converting dicts/lists to JSON for JSONB columns
674
+ values = []
675
+ for row in batch:
676
+ row_values = []
677
+ for col in columns:
678
+ val = row.get(col)
679
+ # Convert dict/list to JSON string for JSONB columns
680
+ if col in ["metadata", "tools_used"] and val is not None:
681
+ if isinstance(val, (dict, list)):
682
+ val = json.dumps(val)
683
+ row_values.append(val)
684
+ values.append(row_values)
685
+
686
+ with conn.cursor() as cur:
687
+ execute_batch(cur, query, values)
688
+ conn.commit()
689
+
690
+
691
+ def insert_batch_supabase(client: Client, table: str, batch: List[Dict[str, Any]], on_conflict: str = None):
692
+ """Insert batch into Supabase table."""
693
+ if not batch:
694
+ return
695
+
696
+ if on_conflict:
697
+ client.table(table).upsert(batch, on_conflict=on_conflict).execute()
698
+ else:
699
+ for chunk in chunked(batch, BATCH_SIZE):
700
+ client.table(table).insert(chunk).execute()
701
+
702
+
703
+ def main():
704
+ parser = argparse.ArgumentParser(description="Migrate SQLite analytics/rules data into Supabase.")
705
+ parser.add_argument("--force", action="store_true", help="Insert even if Supabase tables already contain rows.")
706
+ args = parser.parse_args()
707
+
708
+ print("=" * 70)
709
+ print("SQLite to Supabase Migration Tool")
710
+ print("=" * 70)
711
+ print()
712
+
713
+ # Check for SQLite databases first
714
+ root = Path(__file__).resolve().parent
715
+ data_dir = root / "data"
716
+ rules_db = data_dir / "admin_rules.db"
717
+ analytics_db = data_dir / "analytics.db"
718
+
719
+ print("πŸ“ Checking for local SQLite databases:")
720
+ print(f" Rules DB: {rules_db} {'βœ…' if rules_db.exists() else '❌ Not found'}")
721
+ print(f" Analytics DB: {analytics_db} {'βœ…' if analytics_db.exists() else '❌ Not found'}")
722
+ print()
723
+
724
+ if not rules_db.exists() and not analytics_db.exists():
725
+ print("⚠️ No SQLite databases found. Nothing to migrate.")
726
+ return
727
+
728
+ # Determine connection method
729
+ print("πŸ” Checking connection method...")
730
+ method = get_connection_method()
731
+
732
+ if method == "postgresql":
733
+ print(" βœ… Using PostgreSQL direct connection (POSTGRESQL_URL)")
734
+ conn = get_postgres_connection()
735
+ print(" βœ… Connected to PostgreSQL")
736
+ client = None
737
+ check_table = lambda t: check_table_has_rows_postgres(conn, t)
738
+
739
+ # Check if required tables exist
740
+ print()
741
+ print("πŸ“‹ Checking if required tables exist...")
742
+ required_tables = {
743
+ "admin_rules": "supabase_admin_rules_table.sql",
744
+ "tool_usage_events": "supabase_analytics_tables.sql",
745
+ "redflag_violations": "supabase_analytics_tables.sql",
746
+ "rag_search_events": "supabase_analytics_tables.sql",
747
+ "agent_query_events": "supabase_analytics_tables.sql",
748
+ }
749
+ missing_tables = {}
750
+ for table, sql_file in required_tables.items():
751
+ if table_exists_postgres(conn, table):
752
+ print(f" βœ… {table}")
753
+ else:
754
+ print(f" ❌ {table} (missing)")
755
+ if sql_file not in missing_tables:
756
+ missing_tables[sql_file] = []
757
+ missing_tables[sql_file].append(table)
758
+
759
+ if missing_tables:
760
+ print()
761
+ print("⚠️ Some tables are missing! Please create them first:")
762
+ print()
763
+ for sql_file, tables in missing_tables.items():
764
+ print(f" Run '{sql_file}' in Supabase SQL Editor to create:")
765
+ for table in tables:
766
+ print(f" - {table}")
767
+ print()
768
+ print(" Steps:")
769
+ print(" 1. Go to https://app.supabase.com β†’ Your Project β†’ SQL Editor")
770
+ print(" 2. Click 'New query'")
771
+ for sql_file in missing_tables.keys():
772
+ print(f" 3. Open and copy contents of '{sql_file}' from your project")
773
+ print(" 4. Paste into SQL Editor and click 'Run'")
774
+ print(" 5. Run this migration script again")
775
+ print()
776
+ conn.close()
777
+ return
778
+ elif method == "supabase":
779
+ print(" βœ… Using Supabase API (SUPABASE_URL + SUPABASE_SERVICE_KEY)")
780
+ client = load_supabase_client()
781
+ conn = None
782
+ check_table = lambda t: check_table_has_rows_supabase(client, t)
783
+ else:
784
+ print(" ❌ No connection method available!")
785
+ print()
786
+ print(" Please set one of the following in your .env file:")
787
+ print(" - POSTGRESQL_URL=postgresql://user:password@host:port/database")
788
+ print(" OR")
789
+ print(" - SUPABASE_URL=https://xxxxx.supabase.co")
790
+ print(" - SUPABASE_SERVICE_KEY=your_service_role_key")
791
+ return
792
+
793
+ print()
794
+
795
+ print("πŸš€ Starting migration...")
796
+ print()
797
+
798
+ # Migrate using the appropriate method
799
+ if method == "postgresql":
800
+ migrate_rules_postgres(conn, rules_db, args.force, check_table)
801
+ migrate_tool_usage_postgres(conn, analytics_db, args.force, check_table)
802
+ migrate_redflags_postgres(conn, analytics_db, args.force, check_table)
803
+ migrate_rag_searches_postgres(conn, analytics_db, args.force, check_table)
804
+ migrate_agent_queries_postgres(conn, analytics_db, args.force, check_table)
805
+ conn.close()
806
+ else:
807
+ migrate_rules(client, rules_db, args.force)
808
+ migrate_tool_usage(client, analytics_db, args.force)
809
+ migrate_redflags(client, analytics_db, args.force)
810
+ migrate_rag_searches(client, analytics_db, args.force)
811
+ migrate_agent_queries(client, analytics_db, args.force)
812
+
813
+ print()
814
+ print("=" * 70)
815
+ print("πŸŽ‰ Migration completed!")
816
+ print("=" * 70)
817
+ print()
818
+ print("πŸ’‘ Next steps:")
819
+ print(" 1. Verify data in Supabase Dashboard β†’ Table Editor")
820
+ print(" 2. Restart your FastAPI/MCP services to use Supabase backend")
821
+ print(" 3. (Optional) Back up SQLite files before deleting them")
822
+
823
+
824
+ if __name__ == "__main__":
825
+ main()
826
+
setup_env.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Helper script to create or update .env file with Supabase credentials.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ def main():
10
+ print("=" * 70)
11
+ print("Supabase .env Setup Helper")
12
+ print("=" * 70)
13
+ print()
14
+
15
+ env_file = Path(".env")
16
+ env_example = Path("env.example")
17
+
18
+ # Check if .env already exists
19
+ if env_file.exists():
20
+ print("⚠️ .env file already exists!")
21
+ response = input(" Do you want to update it? (y/n): ").strip().lower()
22
+ if response != 'y':
23
+ print(" Skipping. Edit .env manually if needed.")
24
+ return
25
+ print()
26
+
27
+ # Read existing .env if it exists
28
+ existing_vars = {}
29
+ if env_file.exists():
30
+ with open(env_file, 'r') as f:
31
+ for line in f:
32
+ line = line.strip()
33
+ if line and not line.startswith('#') and '=' in line:
34
+ key, value = line.split('=', 1)
35
+ existing_vars[key.strip()] = value.strip()
36
+
37
+ print("Enter your Supabase credentials:")
38
+ print("(You can find these at: https://app.supabase.com β†’ Your Project β†’ Settings β†’ API)")
39
+ print()
40
+
41
+ # Get Supabase URL
42
+ current_url = existing_vars.get('SUPABASE_URL', '')
43
+ if current_url:
44
+ print(f"Current SUPABASE_URL: {current_url[:50]}...")
45
+ response = input("Keep current? (y/n): ").strip().lower()
46
+ if response == 'y':
47
+ supabase_url = current_url
48
+ else:
49
+ supabase_url = input("Enter SUPABASE_URL (https://xxxxx.supabase.co): ").strip()
50
+ else:
51
+ supabase_url = input("Enter SUPABASE_URL (https://xxxxx.supabase.co): ").strip()
52
+
53
+ # Get Supabase Service Key
54
+ current_key = existing_vars.get('SUPABASE_SERVICE_KEY', '')
55
+ if current_key:
56
+ print(f"Current SUPABASE_SERVICE_KEY: {current_key[:20]}...")
57
+ response = input("Keep current? (y/n): ").strip().lower()
58
+ if response == 'y':
59
+ supabase_key = current_key
60
+ else:
61
+ supabase_key = input("Enter SUPABASE_SERVICE_KEY (service_role key): ").strip()
62
+ else:
63
+ supabase_key = input("Enter SUPABASE_SERVICE_KEY (service_role key): ").strip()
64
+
65
+ # Validate
66
+ if not supabase_url.startswith('https://'):
67
+ print("⚠️ Warning: SUPABASE_URL should start with https://")
68
+ if not supabase_key.startswith('eyJ'):
69
+ print("⚠️ Warning: SUPABASE_SERVICE_KEY should start with 'eyJ' (JWT token)")
70
+
71
+ print()
72
+ print("πŸ“ Creating/updating .env file...")
73
+
74
+ # Read env.example as template
75
+ lines = []
76
+ if env_example.exists():
77
+ with open(env_example, 'r') as f:
78
+ lines = f.readlines()
79
+ else:
80
+ # Create basic template
81
+ lines = [
82
+ "# IntegraChat Environment Variables\n",
83
+ "# Supabase Configuration\n",
84
+ "SUPABASE_URL=\n",
85
+ "SUPABASE_SERVICE_KEY=\n",
86
+ ]
87
+
88
+ # Update or add Supabase variables
89
+ updated_lines = []
90
+ url_found = False
91
+ key_found = False
92
+
93
+ for line in lines:
94
+ if line.startswith('SUPABASE_URL='):
95
+ updated_lines.append(f'SUPABASE_URL={supabase_url}\n')
96
+ url_found = True
97
+ elif line.startswith('SUPABASE_SERVICE_KEY='):
98
+ updated_lines.append(f'SUPABASE_SERVICE_KEY={supabase_key}\n')
99
+ key_found = True
100
+ else:
101
+ updated_lines.append(line)
102
+
103
+ # Add if not found
104
+ if not url_found:
105
+ updated_lines.append(f'SUPABASE_URL={supabase_url}\n')
106
+ if not key_found:
107
+ updated_lines.append(f'SUPABASE_SERVICE_KEY={supabase_key}\n')
108
+
109
+ # Write .env file
110
+ with open(env_file, 'w') as f:
111
+ f.writelines(updated_lines)
112
+
113
+ print(f"βœ… .env file created/updated at: {env_file.absolute()}")
114
+ print()
115
+ print("Next steps:")
116
+ print("1. Make sure your Supabase project is active (not paused)")
117
+ print("2. Create the tables in Supabase:")
118
+ print(" - Run supabase_admin_rules_table.sql in SQL Editor")
119
+ print(" - Run supabase_analytics_tables.sql in SQL Editor")
120
+ print("3. Test the connection:")
121
+ print(" python check_supabase_rules.py")
122
+ print("4. Run the migration:")
123
+ print(" python migrate_sqlite_to_supabase.py")
124
+
125
+ if __name__ == "__main__":
126
+ main()
127
+
supabase_analytics_tables.sql ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- =============================================================
2
+ -- Supabase Table Schemas for Analytics Storage
3
+ -- =============================================================
4
+ -- Run this SQL in the Supabase SQL Editor to mirror the SQLite
5
+ -- analytics schema (tool usage, red flags, RAG searches, queries)
6
+ -- =============================================================
7
+
8
+ CREATE TABLE IF NOT EXISTS tool_usage_events (
9
+ id BIGSERIAL PRIMARY KEY,
10
+ tenant_id TEXT NOT NULL,
11
+ user_id TEXT,
12
+ tool_name TEXT NOT NULL,
13
+ "timestamp" BIGINT NOT NULL,
14
+ latency_ms INTEGER,
15
+ tokens_used INTEGER,
16
+ success BOOLEAN DEFAULT TRUE,
17
+ error_message TEXT,
18
+ metadata JSONB
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_tool_usage_tenant_timestamp
22
+ ON tool_usage_events (tenant_id, "timestamp");
23
+
24
+
25
+ CREATE TABLE IF NOT EXISTS redflag_violations (
26
+ id BIGSERIAL PRIMARY KEY,
27
+ tenant_id TEXT NOT NULL,
28
+ user_id TEXT,
29
+ rule_id TEXT NOT NULL,
30
+ rule_pattern TEXT,
31
+ severity TEXT NOT NULL,
32
+ matched_text TEXT,
33
+ confidence DOUBLE PRECISION,
34
+ message_preview TEXT,
35
+ "timestamp" BIGINT NOT NULL
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_redflag_tenant_timestamp
39
+ ON redflag_violations (tenant_id, "timestamp");
40
+
41
+
42
+ CREATE TABLE IF NOT EXISTS rag_search_events (
43
+ id BIGSERIAL PRIMARY KEY,
44
+ tenant_id TEXT NOT NULL,
45
+ query TEXT NOT NULL,
46
+ hits_count INTEGER,
47
+ avg_score DOUBLE PRECISION,
48
+ top_score DOUBLE PRECISION,
49
+ "timestamp" BIGINT NOT NULL,
50
+ latency_ms INTEGER
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_rag_search_tenant_timestamp
54
+ ON rag_search_events (tenant_id, "timestamp");
55
+
56
+
57
+ CREATE TABLE IF NOT EXISTS agent_query_events (
58
+ id BIGSERIAL PRIMARY KEY,
59
+ tenant_id TEXT NOT NULL,
60
+ user_id TEXT,
61
+ message_preview TEXT,
62
+ intent TEXT,
63
+ tools_used JSONB,
64
+ total_tokens INTEGER,
65
+ total_latency_ms INTEGER,
66
+ success BOOLEAN DEFAULT TRUE,
67
+ "timestamp" BIGINT NOT NULL
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_agent_query_tenant_timestamp
71
+ ON agent_query_events (tenant_id, "timestamp");
72
+
73
+
74
+ -- =============================================================
75
+ -- Optional: Enable Row Level Security and service-role policy
76
+ -- =============================================================
77
+ ALTER TABLE tool_usage_events ENABLE ROW LEVEL SECURITY;
78
+ ALTER TABLE redflag_violations ENABLE ROW LEVEL SECURITY;
79
+ ALTER TABLE rag_search_events ENABLE ROW LEVEL SECURITY;
80
+ ALTER TABLE agent_query_events ENABLE ROW LEVEL SECURITY;
81
+
82
+ CREATE POLICY "Service role can manage tool usage"
83
+ ON tool_usage_events FOR ALL
84
+ USING (true) WITH CHECK (true);
85
+
86
+ CREATE POLICY "Service role can manage red flags"
87
+ ON redflag_violations FOR ALL
88
+ USING (true) WITH CHECK (true);
89
+
90
+ CREATE POLICY "Service role can manage rag searches"
91
+ ON rag_search_events FOR ALL
92
+ USING (true) WITH CHECK (true);
93
+
94
+ CREATE POLICY "Service role can manage agent queries"
95
+ ON agent_query_events FOR ALL
96
+ USING (true) WITH CHECK (true);
97
+
98
+ -- =============================================================
99
+ -- After running this script restart your FastAPI/MCP services
100
+ -- so they detect the Supabase analytics backend.
101
+ -- =============================================================
102
+
verify_supabase_key.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Quick script to verify your Supabase API key format and connection.
4
+ """
5
+
6
+ import os
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ url = os.getenv("SUPABASE_URL")
12
+ key = os.getenv("SUPABASE_SERVICE_KEY")
13
+
14
+ print("=" * 70)
15
+ print("Supabase API Key Verification")
16
+ print("=" * 70)
17
+ print()
18
+
19
+ if not url:
20
+ print("❌ SUPABASE_URL is not set in .env file")
21
+ exit(1)
22
+
23
+ if not key:
24
+ print("❌ SUPABASE_SERVICE_KEY is not set in .env file")
25
+ exit(1)
26
+
27
+ # Clean the key
28
+ key = key.strip()
29
+
30
+ print(f"πŸ“‹ SUPABASE_URL: {url[:30]}...")
31
+ print(f"πŸ“‹ SUPABASE_SERVICE_KEY: {key[:20]}...{key[-10:] if len(key) > 30 else ''} ({len(key)} chars)")
32
+ print()
33
+
34
+ # Check key format
35
+ issues = []
36
+
37
+ if not key.startswith("eyJ"):
38
+ issues.append("❌ Key doesn't start with 'eyJ' (not a JWT token)")
39
+
40
+ if len(key) < 100:
41
+ issues.append(f"❌ Key is too short ({len(key)} chars, expected ~200+)")
42
+
43
+ if len(key) > 500:
44
+ issues.append(f"⚠️ Key is unusually long ({len(key)} chars)")
45
+
46
+ if " " in key or "\n" in key or "\t" in key:
47
+ issues.append("❌ Key contains whitespace (spaces, newlines, tabs)")
48
+
49
+ if key.startswith('"') or key.endswith('"'):
50
+ issues.append("❌ Key is wrapped in quotes (remove quotes from .env)")
51
+
52
+ if key.startswith("'") or key.endswith("'"):
53
+ issues.append("❌ Key is wrapped in single quotes (remove quotes from .env)")
54
+
55
+ if issues:
56
+ print("⚠️ Issues found with API key format:")
57
+ for issue in issues:
58
+ print(f" {issue}")
59
+ print()
60
+ else:
61
+ print("βœ… Key format looks good!")
62
+ print()
63
+
64
+ # Try to connect
65
+ print("πŸ”— Testing connection to Supabase...")
66
+ try:
67
+ from supabase import create_client
68
+ client = create_client(url, key)
69
+
70
+ # Try a simple query
71
+ try:
72
+ client.table("admin_rules").select("id").limit(0).execute()
73
+ print("βœ… Connection successful! API key is valid.")
74
+ print()
75
+ print("πŸ’‘ Next steps:")
76
+ print(" 1. Make sure tables exist (run SQL scripts in Supabase)")
77
+ print(" 2. Run: python migrate_sqlite_to_supabase.py")
78
+ except Exception as e:
79
+ error_str = str(e)
80
+ if "Invalid API key" in error_str or "401" in error_str:
81
+ print("❌ Connection failed: Invalid API key")
82
+ print()
83
+ print("πŸ”§ How to fix:")
84
+ print(" 1. Go to https://app.supabase.com")
85
+ print(" 2. Select your project")
86
+ print(" 3. Go to Settings β†’ API")
87
+ print(" 4. Find 'service_role' key (NOT 'anon' key)")
88
+ print(" 5. Click 'Reveal' to show the full key")
89
+ print(" 6. Copy the ENTIRE key (it's very long)")
90
+ print(" 7. Update SUPABASE_SERVICE_KEY in .env file")
91
+ print(" 8. Make sure NO quotes or spaces around the value")
92
+ elif "does not exist" in error_str or "relation" in error_str.lower():
93
+ print("⚠️ Connection works, but table doesn't exist yet")
94
+ print(" This is OK - create tables first, then migrate")
95
+ else:
96
+ print(f"❌ Connection error: {error_str}")
97
+
98
+ except ImportError:
99
+ print("❌ Supabase Python client not installed")
100
+ print(" Run: pip install supabase")
101
+ except Exception as e:
102
+ print(f"❌ Error: {e}")
103
+
104
+ print()
105
+ print("=" * 70)
106
+
verify_supabase_setup.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Verification script to ensure Supabase is configured and will be used for all future data.
4
+ Run this after migration to confirm everything is set up correctly.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from dotenv import load_dotenv
11
+
12
+ # Add backend to path
13
+ backend_dir = Path(__file__).resolve().parent
14
+ sys.path.insert(0, str(backend_dir))
15
+
16
+ load_dotenv()
17
+
18
+ from backend.api.storage.rules_store import RulesStore
19
+ from backend.api.storage.analytics_store import AnalyticsStore
20
+
21
+ def main():
22
+ print("=" * 70)
23
+ print("Supabase Configuration Verification")
24
+ print("=" * 70)
25
+ print()
26
+
27
+ # Check environment variables
28
+ print("1. Checking Environment Variables:")
29
+ postgres_url = os.getenv("POSTGRESQL_URL")
30
+ supabase_url = os.getenv("SUPABASE_URL")
31
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
32
+
33
+ has_postgres = bool(postgres_url)
34
+ has_supabase_api = bool(supabase_url and supabase_key)
35
+
36
+ if has_postgres:
37
+ masked = postgres_url[:30] + "..." + postgres_url[-20:] if len(postgres_url) > 50 else postgres_url
38
+ print(f" βœ… POSTGRESQL_URL is set: {masked}")
39
+ else:
40
+ print(" ❌ POSTGRESQL_URL is not set")
41
+
42
+ if supabase_url:
43
+ print(f" βœ… SUPABASE_URL is set: {supabase_url[:50]}...")
44
+ else:
45
+ print(" ❌ SUPABASE_URL is not set")
46
+
47
+ if supabase_key:
48
+ if len(supabase_key) > 100:
49
+ print(f" βœ… SUPABASE_SERVICE_KEY is set: {supabase_key[:20]}... ({len(supabase_key)} chars)")
50
+ else:
51
+ print(f" ⚠️ SUPABASE_SERVICE_KEY seems incomplete ({len(supabase_key)} chars, expected 200+)")
52
+ else:
53
+ print(" ❌ SUPABASE_SERVICE_KEY is not set")
54
+
55
+ print()
56
+
57
+ # Check RulesStore
58
+ print("2. Checking RulesStore Configuration:")
59
+ try:
60
+ rules_store = RulesStore()
61
+ if rules_store.use_supabase:
62
+ print(" βœ… RulesStore is using Supabase")
63
+ print(f" πŸ“¦ Backend: Supabase (REST API)")
64
+ else:
65
+ print(" ❌ RulesStore is using SQLite (not Supabase)")
66
+ print(" ⚠️ Future rules will be saved to SQLite, not Supabase!")
67
+ print()
68
+ print(" To fix:")
69
+ print(" - Set SUPABASE_URL and SUPABASE_SERVICE_KEY in .env")
70
+ except Exception as e:
71
+ print(f" ❌ Error initializing RulesStore: {e}")
72
+
73
+ print()
74
+
75
+ # Check AnalyticsStore
76
+ print("3. Checking AnalyticsStore Configuration:")
77
+ try:
78
+ analytics_store = AnalyticsStore()
79
+ if analytics_store.use_supabase:
80
+ print(" βœ… AnalyticsStore is using Supabase")
81
+ print(f" πŸ“¦ Backend: Supabase (REST API)")
82
+ else:
83
+ print(" ❌ AnalyticsStore is using SQLite (not Supabase)")
84
+ print(" ⚠️ Future analytics will be saved to SQLite, not Supabase!")
85
+ print()
86
+ print(" To fix:")
87
+ if has_postgres:
88
+ print(" - POSTGRESQL_URL is set, but AnalyticsStore needs SUPABASE_URL + SUPABASE_SERVICE_KEY")
89
+ else:
90
+ print(" - Set SUPABASE_URL and SUPABASE_SERVICE_KEY in .env")
91
+ except Exception as e:
92
+ print(f" ❌ Error initializing AnalyticsStore: {e}")
93
+
94
+ print()
95
+
96
+ # Summary
97
+ print("4. Summary:")
98
+ rules_ok = rules_store.use_supabase if 'rules_store' in locals() else False
99
+ analytics_ok = analytics_store.use_supabase if 'analytics_store' in locals() else False
100
+
101
+ if rules_ok and analytics_ok:
102
+ print(" βœ… All systems configured to use Supabase!")
103
+ print(" βœ… Future data will be saved to Supabase")
104
+ print()
105
+ print(" πŸ’‘ Next steps:")
106
+ print(" 1. Restart your FastAPI/MCP services to apply changes")
107
+ print(" 2. Test by adding a rule or generating analytics")
108
+ print(" 3. Verify data appears in Supabase Dashboard β†’ Table Editor")
109
+ elif rules_ok or analytics_ok:
110
+ print(" ⚠️ Partial configuration:")
111
+ if rules_ok:
112
+ print(" βœ… Rules will use Supabase")
113
+ else:
114
+ print(" ❌ Rules will use SQLite")
115
+ if analytics_ok:
116
+ print(" βœ… Analytics will use Supabase")
117
+ else:
118
+ print(" ❌ Analytics will use SQLite")
119
+ print()
120
+ print(" To fully migrate to Supabase:")
121
+ print(" - Ensure SUPABASE_URL and SUPABASE_SERVICE_KEY are set in .env")
122
+ print(" - Restart your services")
123
+ else:
124
+ print(" ❌ Not configured for Supabase")
125
+ print(" ⚠️ All data will be saved to SQLite")
126
+ print()
127
+ print(" To migrate to Supabase:")
128
+ print(" 1. Set SUPABASE_URL and SUPABASE_SERVICE_KEY in .env")
129
+ print(" 2. Restart your services")
130
+ print(" 3. Run this verification again")
131
+
132
+ print()
133
+ print("=" * 70)
134
+
135
+ if __name__ == "__main__":
136
+ main()
137
+