Spaces:
Sleeping
Sleeping
Commit
Β·
611e2c1
1
Parent(s):
752b9a7
Migrate admin rules and analytics to Supabase
Browse files- README.md +2 -2
- SUPABASE_MIGRATION_COMPLETE.md +125 -0
- SUPABASE_SETUP.md +27 -19
- backend/README.md +1 -1
- backend/api/routes/admin.py +11 -0
- backend/api/services/agent_orchestrator.py +7 -0
- backend/api/storage/analytics_store.py +494 -132
- check_supabase_rules.py +132 -0
- migrate_sqlite_to_supabase.py +826 -0
- setup_env.py +127 -0
- supabase_analytics_tables.sql +102 -0
- verify_supabase_key.py +106 -0
- verify_supabase_setup.py +137 -0
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
|
| 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** β
|
| 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
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 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 |
-
-
|
| 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 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
"""
|
| 25 |
|
| 26 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 109 |
CREATE INDEX IF NOT EXISTS idx_redflag_tenant_timestamp
|
| 110 |
ON redflag_violations(tenant_id, timestamp)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
| 114 |
CREATE INDEX IF NOT EXISTS idx_rag_search_tenant_timestamp
|
| 115 |
ON rag_search_events(tenant_id, timestamp)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 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 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 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 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
| 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 |
+
|