""" graph_db.py Database abstraction for the HF Space. Uses SQLite by default (free, no external service needed). Set SUPABASE_URL + SUPABASE_KEY env vars to switch to Supabase. """ import os import json import uuid import logging import aiosqlite from datetime import datetime, timezone from pathlib import Path log = logging.getLogger("graph_db") DB_PATH = os.environ.get("DB_PATH", "/app/data/graph.db") SUPABASE_URL = os.environ.get("SUPABASE_URL", "") SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "") USE_SUPABASE = bool(SUPABASE_URL and SUPABASE_KEY) def now_iso(): return datetime.now(timezone.utc).isoformat() def new_id(): return str(uuid.uuid4()) def _row_to_dict(row, cursor): if row is None: return None cols = [d[0] for d in cursor.description] return dict(zip(cols, row)) def _rows_to_list(rows, cursor): cols = [d[0] for d in cursor.description] return [dict(zip(cols, r)) for r in rows] def _is_supabase_error(payload) -> bool: """Supabase returns {'code': ..., 'message': ...} on error instead of a list. Treat anything that isn't a list as an error response.""" if isinstance(payload, list): return False if isinstance(payload, dict) and ("code" in payload or "message" in payload or "error" in payload): return True return True class GraphDB: def __init__(self): self._db = None async def init(self): if USE_SUPABASE: log.info("Using Supabase backend") return Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) self._db = await aiosqlite.connect(DB_PATH) await self._db.execute("PRAGMA journal_mode=WAL") await self._db.execute("PRAGMA foreign_keys=ON") await self._create_tables() log.info(f"SQLite ready: {DB_PATH}") async def _create_tables(self): await self._db.executescript(""" CREATE TABLE IF NOT EXISTS agents ( id TEXT PRIMARY KEY, name TEXT NOT NULL, agent_type TEXT, description TEXT, capabilities TEXT, wallet_address TEXT, nwo_agent_id TEXT UNIQUE, nwo_api_key TEXT, persona_color TEXT DEFAULT '#7F77DD', avatar_label TEXT, is_active INTEGER DEFAULT 1, created_at TEXT ); CREATE TABLE IF NOT EXISTS graph_nodes ( id TEXT PRIMARY KEY, user_id TEXT, agent_id TEXT, name TEXT NOT NULL, val REAL DEFAULT 1.0, color TEXT, depth_level INTEGER DEFAULT 0, description TEXT, category TEXT DEFAULT 'topic', actor_type TEXT DEFAULT 'human', x_position REAL, y_position REAL, expand_requested INTEGER DEFAULT 0, expand_done INTEGER DEFAULT 0, nwo_agent_id TEXT, nwo_task_id TEXT, robot_position TEXT, sensor_data TEXT, battery_level REAL, created_at TEXT, updated_at TEXT ); CREATE INDEX IF NOT EXISTS idx_nodes_actor ON graph_nodes(actor_type); CREATE INDEX IF NOT EXISTS idx_nodes_expand ON graph_nodes(expand_requested); CREATE INDEX IF NOT EXISTS idx_nodes_created ON graph_nodes(created_at); CREATE TABLE IF NOT EXISTS graph_links ( id TEXT PRIMARY KEY, source_id TEXT, target_id TEXT, similarity_score REAL DEFAULT 0.5, link_type TEXT DEFAULT 'semantic', connection_reason TEXT, created_by_actor_type TEXT, created_by_id TEXT, created_at TEXT ); CREATE TABLE IF NOT EXISTS graph_posts ( id TEXT PRIMARY KEY, node_id TEXT, content TEXT NOT NULL, actor_type TEXT DEFAULT 'human', actor_id TEXT, nwo_agent_id TEXT, parent_post_id TEXT, metadata TEXT, created_at TEXT ); CREATE INDEX IF NOT EXISTS idx_posts_created ON graph_posts(created_at); CREATE TABLE IF NOT EXISTS robot_telemetry ( id TEXT PRIMARY KEY, nwo_agent_id TEXT, agent_id TEXT, task_id TEXT, rl_session_id TEXT, joint_angles TEXT, gripper_state REAL, position TEXT, battery_level REAL, reward REAL, state_vector TEXT, action_vector TEXT, raw_telemetry TEXT, node_id TEXT, created_at TEXT ); CREATE TABLE IF NOT EXISTS swarm_events ( id TEXT PRIMARY KEY, swarm_id TEXT, event_type TEXT, payload TEXT, node_id TEXT, created_at TEXT ); CREATE TABLE IF NOT EXISTS iot_snapshots ( id TEXT PRIMARY KEY, device_id TEXT, status TEXT, node_id TEXT, created_at TEXT ); CREATE TABLE IF NOT EXISTS expansion_queue ( id TEXT PRIMARY KEY, node_id TEXT, model_used TEXT, prompt_used TEXT, result_raw TEXT, status TEXT DEFAULT 'pending', error_msg TEXT, started_at TEXT, completed_at TEXT, created_at TEXT ); """) await self._db.commit() # --------------------------------------------------------------- # Agents # --------------------------------------------------------------- async def create_agent(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("agents", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) if "capabilities" in data and isinstance(data["capabilities"], list): data["capabilities"] = json.dumps(data["capabilities"]) cols = list(data.keys()) vals = list(data.values()) q = f"INSERT INTO agents ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})" async with self._db.execute(q, vals) as cur: pass await self._db.commit() return data async def get_agent_by_key(self, key: str): if USE_SUPABASE: return await self._sb_get_one("agents", f"nwo_api_key=eq.{key}&is_active=eq.true") async with self._db.execute("SELECT * FROM agents WHERE nwo_api_key=? AND is_active=1", [key]) as cur: row = await cur.fetchone() return _row_to_dict(row, cur) if row else None async def get_agent_by_nwo_id(self, nwo_id: str): if USE_SUPABASE: return await self._sb_get_one("agents", f"nwo_agent_id=eq.{nwo_id}") async with self._db.execute("SELECT * FROM agents WHERE nwo_agent_id=?", [nwo_id]) as cur: row = await cur.fetchone() return _row_to_dict(row, cur) if row else None async def get_agent_by_name(self, name: str): if USE_SUPABASE: return await self._sb_get_one("agents", f"name=eq.{name}") async with self._db.execute("SELECT * FROM agents WHERE name=?", [name]) as cur: row = await cur.fetchone() return _row_to_dict(row, cur) if row else None async def get_robots(self, active_only=True, has_nwo_id=False): if USE_SUPABASE: qs = "agent_type=neq.ai_agent" if active_only: qs += "&is_active=eq.true" rows = await self._sb_get_many("agents", qs) return rows if isinstance(rows, list) else [] q = "SELECT * FROM agents WHERE agent_type != 'ai_agent'" if active_only: q += " AND is_active=1" if has_nwo_id: q += " AND nwo_agent_id IS NOT NULL" async with self._db.execute(q) as cur: rows = await cur.fetchall() return _rows_to_list(rows, cur) # --------------------------------------------------------------- # Nodes # --------------------------------------------------------------- async def create_node(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("graph_nodes", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) data.setdefault("updated_at", now_iso()) for k in ("robot_position", "sensor_data"): if k in data and not isinstance(data[k], str): data[k] = json.dumps(data[k]) data["expand_requested"] = 1 if data.get("expand_requested") else 0 data["expand_done"] = 1 if data.get("expand_done") else 0 cols = list(data.keys()) vals = list(data.values()) q = f"INSERT INTO graph_nodes ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})" await self._db.execute(q, vals) await self._db.commit() return data async def get_nodes(self, limit=500) -> list: if USE_SUPABASE: rows = await self._sb_get_many("graph_nodes", f"order=created_at.desc&limit={limit}") return rows if isinstance(rows, list) else [] async with self._db.execute( "SELECT * FROM graph_nodes ORDER BY created_at DESC LIMIT ?", [limit] ) as cur: rows = await cur.fetchall() result = _rows_to_list(rows, cur) for n in result: for k in ("robot_position", "sensor_data"): if isinstance(n.get(k), str): try: n[k] = json.loads(n[k]) except: pass return result async def get_pending_expansions(self, limit=5) -> list: if USE_SUPABASE: rows = await self._sb_get_many("graph_nodes", f"expand_requested=eq.true&expand_done=eq.false&depth_level=lt.6&limit={limit}&order=created_at.asc") return rows if isinstance(rows, list) else [] async with self._db.execute( "SELECT * FROM graph_nodes WHERE expand_requested=1 AND expand_done=0 AND depth_level < 6 ORDER BY created_at ASC LIMIT ?", [limit] ) as cur: rows = await cur.fetchall() return _rows_to_list(rows, cur) async def queue_expansion(self, node_id: str) -> bool: if USE_SUPABASE: await self._sb_patch("graph_nodes", node_id, { "expand_requested": True, "expand_done": False }) return True await self._db.execute( "UPDATE graph_nodes SET expand_requested=1, expand_done=0 WHERE id=?", [node_id] ) await self._db.commit() return True async def get_node_children(self, node_id: str) -> list: if USE_SUPABASE: links = await self._sb_get_many("graph_links", f"source_id=eq.{node_id}&select=target_id") if not isinstance(links, list) or not links: return [] target_ids = [l["target_id"] for l in links if isinstance(l, dict) and "target_id" in l] if not target_ids: return [] id_filter = ",".join(target_ids) rows = await self._sb_get_many("graph_nodes", f"id=in.({id_filter})&select=id,name") return rows if isinstance(rows, list) else [] async with self._db.execute( "SELECT n.id, n.name FROM graph_nodes n " "JOIN graph_links l ON l.target_id = n.id " "WHERE l.source_id = ?", [node_id] ) as cur: rows = await cur.fetchall() return [{"id": r[0], "name": r[1]} for r in rows] async def mark_node_expanded(self, node_id: str): if USE_SUPABASE: return await self._sb_patch("graph_nodes", node_id, {"expand_done": True}) await self._db.execute("UPDATE graph_nodes SET expand_done=1 WHERE id=?", [node_id]) await self._db.commit() # --------------------------------------------------------------- # Links # --------------------------------------------------------------- async def create_link(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("graph_links", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) cols, vals = list(data.keys()), list(data.values()) await self._db.execute(f"INSERT INTO graph_links ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})", vals) await self._db.commit() return data async def get_links(self, limit=1000) -> list: if USE_SUPABASE: rows = await self._sb_get_many("graph_links", f"limit={limit}") return rows if isinstance(rows, list) else [] async with self._db.execute("SELECT * FROM graph_links LIMIT ?", [limit]) as cur: rows = await cur.fetchall() return _rows_to_list(rows, cur) # --------------------------------------------------------------- # Posts # --------------------------------------------------------------- async def create_post(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("graph_posts", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) if "metadata" in data and not isinstance(data["metadata"], str): data["metadata"] = json.dumps(data["metadata"]) cols, vals = list(data.keys()), list(data.values()) await self._db.execute(f"INSERT INTO graph_posts ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})", vals) await self._db.commit() return data async def get_posts(self, since=None, limit=50) -> list: if USE_SUPABASE: qs = f"order=created_at.desc&limit={limit}" if since: qs += f"&created_at=gt.{since}" rows = await self._sb_get_many("graph_posts", qs) return rows if isinstance(rows, list) else [] q = "SELECT * FROM graph_posts" params = [] if since: q += " WHERE created_at > ?" params.append(since) q += " ORDER BY created_at DESC LIMIT ?" params.append(limit) async with self._db.execute(q, params) as cur: rows = await cur.fetchall() result = _rows_to_list(rows, cur) for p in result: if isinstance(p.get("metadata"), str): try: p["metadata"] = json.loads(p["metadata"]) except: pass return result # --------------------------------------------------------------- # Telemetry # --------------------------------------------------------------- async def create_telemetry(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("robot_telemetry", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) for k in ("joint_angles", "state_vector", "action_vector", "position", "raw_telemetry"): if k in data and not isinstance(data[k], str): data[k] = json.dumps(data[k]) cols, vals = list(data.keys()), list(data.values()) await self._db.execute(f"INSERT INTO robot_telemetry ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})", vals) await self._db.commit() return data async def get_telemetry(self, nwo_agent_id: str, limit=20) -> list: if USE_SUPABASE: rows = await self._sb_get_many("robot_telemetry", f"nwo_agent_id=eq.{nwo_agent_id}&order=created_at.desc&limit={limit}") return rows if isinstance(rows, list) else [] async with self._db.execute( "SELECT * FROM robot_telemetry WHERE nwo_agent_id=? ORDER BY created_at DESC LIMIT ?", [nwo_agent_id, limit] ) as cur: rows = await cur.fetchall() return _rows_to_list(rows, cur) # --------------------------------------------------------------- # Swarm + IoT # --------------------------------------------------------------- async def create_swarm_event(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("swarm_events", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) if "payload" in data and not isinstance(data["payload"], str): data["payload"] = json.dumps(data["payload"]) cols, vals = list(data.keys()), list(data.values()) await self._db.execute(f"INSERT INTO swarm_events ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})", vals) await self._db.commit() return data async def create_iot_snapshot(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("iot_snapshots", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) if "status" in data and not isinstance(data["status"], str): data["status"] = json.dumps(data["status"]) cols, vals = list(data.keys()), list(data.values()) await self._db.execute(f"INSERT INTO iot_snapshots ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})", vals) await self._db.commit() return data # --------------------------------------------------------------- # Expansion queue # --------------------------------------------------------------- async def create_expansion_queue_entry(self, data: dict) -> dict: if USE_SUPABASE: return await self._sb_post("expansion_queue", data) data.setdefault("id", new_id()) data.setdefault("created_at", now_iso()) cols, vals = list(data.keys()), list(data.values()) await self._db.execute(f"INSERT INTO expansion_queue ({','.join(cols)}) VALUES ({','.join('?' * len(vals))})", vals) await self._db.commit() return data async def update_expansion_queue(self, eq_id: str, data: dict): if USE_SUPABASE: return await self._sb_patch("expansion_queue", eq_id, data) sets = ", ".join(f"{k}=?" for k in data) vals = list(data.values()) + [eq_id] await self._db.execute(f"UPDATE expansion_queue SET {sets} WHERE id=?", vals) await self._db.commit() # --------------------------------------------------------------- # Supabase REST helpers (hardened — detect errors and log them) # --------------------------------------------------------------- async def _sb_post(self, table, data): import aiohttp url = f"{SUPABASE_URL}/rest/v1/{table}" headers = { "apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json", "Prefer": "return=representation" } try: async with aiohttp.ClientSession() as sess: async with sess.post(url, json=data, headers=headers) as resp: result = await resp.json() if resp.status >= 400: log.error(f"Supabase POST {table} failed: HTTP {resp.status} body={result}") return {} return result[0] if isinstance(result, list) and result else (result if isinstance(result, dict) else {}) except Exception as e: log.error(f"Supabase POST {table} exception: {e}") return {} async def _sb_get_many(self, table, qs=""): """Always returns a list. Logs and returns [] on any error.""" import aiohttp url = f"{SUPABASE_URL}/rest/v1/{table}?{qs}" headers = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}"} try: async with aiohttp.ClientSession() as sess: async with sess.get(url, headers=headers) as resp: result = await resp.json() if resp.status >= 400: log.error(f"Supabase GET {table} failed: HTTP {resp.status} body={result} url={url}") return [] if not isinstance(result, list): log.error(f"Supabase GET {table} returned non-list: {result} url={url}") return [] return result except Exception as e: log.error(f"Supabase GET {table} exception: {e}") return [] async def _sb_get_one(self, table, qs): rows = await self._sb_get_many(table, qs + "&limit=1") return rows[0] if rows else None async def _sb_patch(self, table, row_id, data): import aiohttp url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{row_id}" headers = { "apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json" } try: async with aiohttp.ClientSession() as sess: async with sess.patch(url, json=data, headers=headers) as resp: if resp.status >= 400: body = await resp.text() log.error(f"Supabase PATCH {table} failed: HTTP {resp.status} body={body[:400]}") except Exception as e: log.error(f"Supabase PATCH {table} exception: {e}") db = GraphDB()