""" Supabase Client for Chat Storage ================================= Handles conversation persistence with: - Session management - Message CRUD - History retrieval """ import os import uuid from datetime import datetime from typing import Optional, List, Dict, Any import json # Try to import supabase, fallback to in-memory storage try: from supabase import create_client, Client SUPABASE_AVAILABLE = True except ImportError: SUPABASE_AVAILABLE = False print("Warning: supabase-py not installed, using in-memory storage") class SupabaseClient: """Client for Supabase chat storage operations.""" def __init__(self): self.client: Optional[Client] = None self._memory_sessions: Dict[str, Dict] = {} self._memory_messages: Dict[str, List[Dict]] = {} if SUPABASE_AVAILABLE: url = os.environ.get("SUPABASE_URL") key = os.environ.get("SUPABASE_KEY") if url and key: try: self.client = create_client(url, key) print("Supabase client initialized successfully") except Exception as e: print(f"Failed to initialize Supabase: {e}") self.client = None else: print("SUPABASE_URL or SUPABASE_KEY not set") def is_configured(self) -> bool: """Check if Supabase is properly configured.""" return self.client is not None # ========================================================================= # SESSION OPERATIONS # ========================================================================= def create_session(self, user_id: str, title: str = "New Conversation") -> Dict: """Create a new chat session.""" session_id = str(uuid.uuid4()) now = datetime.now().isoformat() session_data = { "id": session_id, "user_id": user_id, "title": title, "created_at": now, "updated_at": now } if self.client: try: result = self.client.table("chat_sessions").insert(session_data).execute() return result.data[0] if result.data else session_data except Exception as e: print(f"Supabase create session error: {e}") # Fallback to memory # In-memory fallback self._memory_sessions[session_id] = session_data self._memory_messages[session_id] = [] return session_data def get_session(self, session_id: str) -> Optional[Dict]: """Get session details.""" if self.client: try: result = self.client.table("chat_sessions").select("*").eq("id", session_id).execute() return result.data[0] if result.data else None except Exception as e: print(f"Supabase get session error: {e}") return self._memory_sessions.get(session_id) def get_user_sessions(self, user_id: str) -> List[Dict]: """Get all sessions for a user, ordered by most recent.""" if self.client: try: result = self.client.table("chat_sessions")\ .select("*, chat_messages(count)")\ .eq("user_id", user_id)\ .order("updated_at", desc=True)\ .execute() sessions = [] for session in result.data: msg_count = 0 if session.get("chat_messages"): msg_count = session["chat_messages"][0].get("count", 0) if session["chat_messages"] else 0 sessions.append({ "id": session["id"], "title": session["title"], "created_at": session["created_at"], "updated_at": session["updated_at"], "message_count": msg_count }) return sessions except Exception as e: print(f"Supabase get sessions error: {e}") # In-memory fallback return [ {**s, "message_count": len(self._memory_messages.get(s["id"], []))} for s in self._memory_sessions.values() if s.get("user_id") == user_id ] def update_session_timestamp(self, session_id: str): """Update session's updated_at timestamp.""" now = datetime.now().isoformat() if self.client: try: self.client.table("chat_sessions")\ .update({"updated_at": now})\ .eq("id", session_id)\ .execute() except Exception as e: print(f"Supabase update timestamp error: {e}") else: if session_id in self._memory_sessions: self._memory_sessions[session_id]["updated_at"] = now def update_session_title(self, session_id: str, title: str): """Update session title.""" if self.client: try: self.client.table("chat_sessions")\ .update({"title": title, "updated_at": datetime.now().isoformat()})\ .eq("id", session_id)\ .execute() except Exception as e: print(f"Supabase update title error: {e}") else: if session_id in self._memory_sessions: self._memory_sessions[session_id]["title"] = title def delete_session(self, session_id: str): """Delete session and all its messages.""" if self.client: try: # Delete messages first (foreign key constraint) self.client.table("chat_messages").delete().eq("session_id", session_id).execute() self.client.table("chat_sessions").delete().eq("id", session_id).execute() except Exception as e: print(f"Supabase delete session error: {e}") else: self._memory_sessions.pop(session_id, None) self._memory_messages.pop(session_id, None) # ========================================================================= # MESSAGE OPERATIONS # ========================================================================= def add_message( self, session_id: str, role: str, content: str, context_used: Optional[List[str]] = None ) -> str: """Add a message to a session.""" message_id = str(uuid.uuid4()) now = datetime.now().isoformat() message_data = { "id": message_id, "session_id": session_id, "role": role, "content": content, "context_used": json.dumps(context_used) if context_used else None, "created_at": now } if self.client: try: self.client.table("chat_messages").insert(message_data).execute() return message_id except Exception as e: print(f"Supabase add message error: {e}") # In-memory fallback if session_id not in self._memory_messages: self._memory_messages[session_id] = [] self._memory_messages[session_id].append(message_data) return message_id def get_messages(self, session_id: str, limit: int = 100) -> List[Dict]: """Get messages for a session, ordered by creation time.""" if self.client: try: result = self.client.table("chat_messages")\ .select("*")\ .eq("session_id", session_id)\ .order("created_at")\ .limit(limit)\ .execute() return result.data if result.data else [] except Exception as e: print(f"Supabase get messages error: {e}") # In-memory fallback messages = self._memory_messages.get(session_id, []) return sorted(messages, key=lambda m: m.get("created_at", ""))[:limit] def delete_message(self, message_id: str): """Delete a specific message.""" if self.client: try: self.client.table("chat_messages").delete().eq("id", message_id).execute() except Exception as e: print(f"Supabase delete message error: {e}") else: for session_id, messages in self._memory_messages.items(): self._memory_messages[session_id] = [ m for m in messages if m.get("id") != message_id ] # ========================================================================= # FIELD DATA OPERATIONS # ========================================================================= def get_user_fields(self, user_id: str) -> List[Dict]: """Get all farmland fields for a user.""" if not self.client: return [] try: result = self.client.table("coordinates_quad")\ .select("id, name, crop_type, area_acres, lat1, lon1, lat2, lon2, lat3, lon3, lat4, lon4")\ .eq("user_id", user_id)\ .execute() return result.data if result.data else [] except Exception as e: print(f"Supabase get fields error: {e}") return [] def get_field_by_id(self, field_id: str) -> Optional[Dict]: """Get a specific field by ID.""" if not self.client: return None try: result = self.client.table("coordinates_quad")\ .select("*")\ .eq("id", field_id)\ .single()\ .execute() return result.data except Exception as e: print(f"Supabase get field error: {e}") return None def get_field_context(self, user_id: str, field_name: Optional[str] = None) -> Dict[str, Any]: """ Get field context data formatted for chatbot. Returns: { "field_name": str, "crop_type": str, "area_acres": float, "coordinates": { "center_lat": float, "center_lon": float, "bbox": [lon_min, lat_min, lon_max, lat_max] }, "all_fields": List[{name, crop_type, area}] } """ fields = self.get_user_fields(user_id) if not fields: return { "field_name": "No fields registered", "crop_type": "Unknown", "area_acres": 0, "coordinates": None, "all_fields": [] } # Find specific field or use first one selected_field = None if field_name: for f in fields: if f.get("name", "").lower() == field_name.lower(): selected_field = f break if not selected_field: selected_field = fields[0] # Calculate center and bounding box lats = [] lons = [] for i in range(1, 5): lat = selected_field.get(f"lat{i}") lon = selected_field.get(f"lon{i}") if lat is not None and lon is not None: lats.append(float(lat)) lons.append(float(lon)) coordinates = None if lats and lons: center_lat = sum(lats) / len(lats) center_lon = sum(lons) / len(lons) coordinates = { "center_lat": round(center_lat, 6), "center_lon": round(center_lon, 6), "bbox": [min(lons), min(lats), max(lons), max(lats)] } return { "field_name": selected_field.get("name", "Unnamed Field"), "crop_type": selected_field.get("crop_type", "Unknown"), "area_acres": selected_field.get("area_acres", 0), "coordinates": coordinates, "all_fields": [ { "name": f.get("name"), "crop_type": f.get("crop_type"), "area_acres": f.get("area_acres") } for f in fields ] } def get_user_profile(self, user_id: str) -> Optional[Dict]: """Get user profile and questionnaire data.""" if not self.client: return None try: # user_profiles columns: user_id, full_name, email, phone_number, # date_of_birth, address, avatar_url, questionnaire_data, updated_at result = self.client.table("user_profiles")\ .select("questionnaire_data, full_name, address")\ .eq("user_id", user_id)\ .execute() # Return first result if any, otherwise None if result.data and len(result.data) > 0: return result.data[0] return None except Exception as e: print(f"Supabase get profile error: {e}") return None