- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
281 lines
9.3 KiB
Python
281 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Migrate RAG data from Redis to Supabase rag_vectors table.
|
|
Reads all documents from 10 Redis collections and upserts into pgvector.
|
|
Handles mixed embedding dimensions by padding to 640.
|
|
Processes in batches of 100. Idempotent (skips already-migrated docs).
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import httpx
|
|
import redis.asyncio as aioredis
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv("/app/.env", override=True)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Target dimension - max across all collections is 640 (token_analysis)
|
|
TARGET_DIM = 640
|
|
|
|
COLLECTIONS = [
|
|
"wallet_profiles",
|
|
"token_analysis",
|
|
"known_scams",
|
|
"scam_patterns",
|
|
"forensic_reports",
|
|
"market_intel",
|
|
"contract_audits",
|
|
"news_articles",
|
|
"transaction_patterns",
|
|
"general",
|
|
]
|
|
|
|
BATCH_SIZE = 100
|
|
|
|
|
|
def _get_url():
|
|
return os.environ.get("SUPABASE_URL", "")
|
|
|
|
|
|
def _get_key():
|
|
return os.environ.get("SUPABASE_SERVICE_KEY", "") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
|
|
|
|
def _get_headers():
|
|
key = _get_key()
|
|
return {
|
|
"apikey": key,
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
"Prefer": "resolution=merge-duplicates",
|
|
}
|
|
|
|
|
|
def _get_redis_password():
|
|
return os.environ.get("REDIS_PASSWORD", "")
|
|
|
|
|
|
def pad_vector(vec: list[float], target_dim: int) -> list[float]:
|
|
"""Pad or truncate embedding vector to target dimension."""
|
|
if len(vec) == target_dim:
|
|
return vec
|
|
if len(vec) > target_dim:
|
|
return vec[:target_dim]
|
|
return vec + [0.0] * (target_dim - len(vec))
|
|
|
|
|
|
async def get_existing_ids(client: httpx.AsyncClient) -> set:
|
|
"""Fetch all existing doc IDs from rag_vectors to skip already-migrated docs."""
|
|
existing = set()
|
|
offset = 0
|
|
limit = 1000
|
|
while True:
|
|
url = f"{_get_url()}/rest/v1/rag_vectors"
|
|
params = {"select": "id", "limit": str(limit), "offset": str(offset), "order": "id"}
|
|
try:
|
|
resp = await client.get(url, params=params, headers=_get_headers(), timeout=30)
|
|
if resp.status_code != 200:
|
|
logger.warning(f"Could not fetch existing IDs: {resp.status_code} {resp.text[:300]}")
|
|
break
|
|
rows = resp.json() if resp.text else []
|
|
if not rows:
|
|
break
|
|
for row in rows:
|
|
existing.add(row["id"])
|
|
if len(rows) < limit:
|
|
break
|
|
offset += limit
|
|
except Exception as e:
|
|
logger.warning(f"Error fetching existing IDs: {e}")
|
|
break
|
|
logger.info(f"Found {len(existing)} existing docs in rag_vectors")
|
|
return existing
|
|
|
|
|
|
async def insert_batch(client: httpx.AsyncClient, rows: list[dict]) -> int:
|
|
"""Insert a batch of rows into rag_vectors via Supabase REST API."""
|
|
url = f"{_get_url()}/rest/v1/rag_vectors"
|
|
params = {"on_conflict": "id"}
|
|
try:
|
|
resp = await client.post(url, json=rows, params=params, headers=_get_headers(), timeout=120)
|
|
if resp.status_code in (200, 201):
|
|
return len(rows)
|
|
else:
|
|
logger.error(f"Batch insert failed: {resp.status_code} {resp.text[:500]}")
|
|
# Fallback: try one by one
|
|
success = 0
|
|
for row in rows:
|
|
try:
|
|
r2 = await client.post(url, json=[row], params=params, headers=_get_headers(), timeout=60)
|
|
if r2.status_code in (200, 201):
|
|
success += 1
|
|
else:
|
|
if r2.status_code == 400 and "embedding" in (r2.text or ""):
|
|
logger.warning(f"Skipping {row['id']} due to embedding error")
|
|
else:
|
|
logger.warning(f"Single insert failed for {row['id']}: {r2.status_code} {r2.text[:200]}")
|
|
except Exception as e:
|
|
logger.warning(f"Single insert error for {row['id']}: {e}")
|
|
return success
|
|
except Exception as e:
|
|
logger.error(f"Batch insert exception: {e}")
|
|
return 0
|
|
|
|
|
|
async def migrate_collection(r: aioredis.Redis, client: httpx.AsyncClient, collection: str, existing_ids: set) -> int:
|
|
"""Migrate all docs from one Redis collection to Supabase."""
|
|
idx_key = f"rag:idx:{collection}"
|
|
doc_ids = await r.smembers(idx_key)
|
|
if not doc_ids:
|
|
logger.info(f"Collection {collection}: empty, skipping")
|
|
return 0
|
|
|
|
total_docs = len(doc_ids)
|
|
logger.info(f"Collection {collection}: {total_docs} docs to process")
|
|
|
|
# Filter out already-migrated docs
|
|
to_migrate = [did for did in doc_ids if did not in existing_ids]
|
|
skipped = total_docs - len(to_migrate)
|
|
if skipped > 0:
|
|
logger.info(f" Skipping {skipped} already-migrated docs")
|
|
|
|
migrated = 0
|
|
batch_rows = []
|
|
batch_count = 0
|
|
errors = 0
|
|
|
|
for i, doc_id in enumerate(to_migrate):
|
|
doc_key = f"rag:{collection}:{doc_id}"
|
|
try:
|
|
data = await r.get(doc_key)
|
|
if not data:
|
|
logger.debug(f" Doc {doc_id}: not found in Redis")
|
|
continue
|
|
|
|
doc = json.loads(data)
|
|
|
|
# Extract fields
|
|
vector = doc.get("vector", [])
|
|
content = doc.get("content", "") or ""
|
|
metadata = doc.get("metadata", {}) or {}
|
|
|
|
source = metadata.get("source", "") or doc.get("source", "") or ""
|
|
severity = metadata.get("severity", "") or doc.get("severity", "") or "medium"
|
|
chain = metadata.get("chain", "") or doc.get("chain", "") or ""
|
|
|
|
# Pad vector to target dimension
|
|
if vector:
|
|
padded = pad_vector(vector, TARGET_DIM)
|
|
else:
|
|
padded = [0.0] * TARGET_DIM
|
|
logger.debug(f" Doc {doc_id}: no embedding, using zero vector")
|
|
|
|
row = {
|
|
"id": str(doc_id),
|
|
"collection": collection,
|
|
"content": content[:10000],
|
|
"embedding": padded,
|
|
"metadata": json.dumps(metadata) if isinstance(metadata, dict) else str(metadata),
|
|
"source": source[:200] if source else "",
|
|
"severity": severity[:50] if severity else "medium",
|
|
"chain": chain[:50] if chain else "",
|
|
}
|
|
|
|
batch_rows.append(row)
|
|
|
|
if len(batch_rows) >= BATCH_SIZE:
|
|
count = await insert_batch(client, batch_rows)
|
|
migrated += count
|
|
batch_count += 1
|
|
logger.info(
|
|
f" Batch {batch_count}: inserted {count}/{len(batch_rows)} ({migrated}/{len(to_migrate)} total)"
|
|
)
|
|
batch_rows = []
|
|
await asyncio.sleep(0.05)
|
|
|
|
except json.JSONDecodeError as e:
|
|
errors += 1
|
|
if errors <= 5:
|
|
logger.warning(f" Doc {doc_id}: JSON decode error: {e}")
|
|
except Exception as e:
|
|
errors += 1
|
|
if errors <= 5:
|
|
logger.warning(f" Doc {doc_id}: error: {e}")
|
|
|
|
# Progress every 500 docs
|
|
if (i + 1) % 500 == 0:
|
|
logger.info(f" Progress: processed {i + 1}/{len(to_migrate)}, migrated {migrated}, errors {errors}")
|
|
|
|
# Flush remaining batch
|
|
if batch_rows:
|
|
count = await insert_batch(client, batch_rows)
|
|
migrated += count
|
|
batch_count += 1
|
|
logger.info(f" Final batch {batch_count}: inserted {count}/{len(batch_rows)}")
|
|
|
|
logger.info(
|
|
f"Collection {collection}: migrated {migrated}/{len(to_migrate)} docs ({skipped} skipped, {errors} errors)"
|
|
)
|
|
return migrated
|
|
|
|
|
|
async def main():
|
|
logger.info("Starting Redis -> Supabase rag_vectors migration")
|
|
logger.info(f"Supabase URL: {_get_url()}")
|
|
logger.info(f"Redis: {os.environ.get('REDIS_HOST', 'rmi-redis')}:{os.environ.get('REDIS_PORT', '6379')}")
|
|
logger.info(f"Target vector dimension: {TARGET_DIM}")
|
|
logger.info(f"Collections: {COLLECTIONS}")
|
|
|
|
if not _get_url() or not _get_key():
|
|
logger.error("SUPABASE_URL and SUPABASE_SERVICE_KEY must be set")
|
|
sys.exit(1)
|
|
|
|
redis_password = _get_redis_password()
|
|
redis_host = os.environ.get("REDIS_HOST", "rmi-redis")
|
|
redis_port = int(os.environ.get("REDIS_PORT", "6379"))
|
|
|
|
# Connect to Redis
|
|
r = aioredis.Redis(
|
|
host=redis_host,
|
|
port=redis_port,
|
|
password=redis_password,
|
|
decode_responses=True,
|
|
)
|
|
await r.ping()
|
|
logger.info("Redis connected")
|
|
|
|
# HTTP client for Supabase
|
|
async with httpx.AsyncClient(timeout=120) as client:
|
|
# Get existing IDs for idempotency
|
|
existing_ids = await get_existing_ids(client)
|
|
|
|
total_migrated = 0
|
|
start_time = time.time()
|
|
|
|
for collection in COLLECTIONS:
|
|
try:
|
|
count = await migrate_collection(r, client, collection, existing_ids)
|
|
total_migrated += count
|
|
except Exception as e:
|
|
logger.error(f"Collection {collection} failed: {e}")
|
|
|
|
elapsed = time.time() - start_time
|
|
logger.info("=" * 60)
|
|
logger.info(f"Migration complete: {total_migrated} docs migrated in {elapsed:.1f}s")
|
|
|
|
await r.aclose()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|