Socrates_docker / db_user.py
AlessandroAmodioNGI's picture
fix: make UI-language + user-row writes non-fatal and retried
95b007f
Raw
History Blame Contribute Delete
17.3 kB
import os
import json
from db_client import supabase, db_execute
from classify_parameters import TRACKED_FIELDS
### HELPER FOR IMPORTING AND EXPORTING USER PROFILE TO SUPABASE
###############################################################
def load_user_info(user_id: str):
"""Fetch user profile from Supabase and convert types back to Python."""
resp = supabase.table("user_profiles").select("*").eq("id", user_id).execute()
if not resp.data:
print(f"⚠️ No profile found for {user_id}, returning empty profile")
return {"user_profile": {}, "interaction_log": {}}
raw_profile = resp.data[0]
clean_profile = {}
for field in TRACKED_FIELDS:
value = raw_profile.get(field)
if value is None:
clean_profile[field] = None
continue
# Kids → parse JSONB string back to list[dict]
if field == "kids":
try:
if isinstance(value, str): # Supabase might return it as JSON string
clean_profile[field] = json.loads(value)
else:
clean_profile[field] = value
except Exception as e:
print(f"⚠️ Could not parse kids JSON: {value}, error={e}")
clean_profile[field] = []
# Arrays → keep as Python lists
elif field in ["hobbies", "interests", "sports_played", "sports_watched", "language_spoken"]:
if isinstance(value, list):
clean_profile[field] = value
elif isinstance(value, str):
# fallback: single string returned, wrap into list
clean_profile[field] = [value]
else:
clean_profile[field] = []
# Default → text
else:
clean_profile[field] = str(value)
return {"user_profile": clean_profile, "interaction_log": {}}
def save_user_info(user_info: dict, user_id: str):
"""Save/update user profile in Supabase, merging with existing values."""
profile = user_info.get("user_profile", {})
if not profile:
print("⚠️ Nothing to save, user_profile empty")
return
# Load existing profile first
existing_data = load_user_info(user_id).get("user_profile", {})
clean_profile = {}
for field in TRACKED_FIELDS:
new_value = profile.get(field)
old_value = existing_data.get(field)
# ---- Handle Kids specially ----
if field == "kids":
try:
old_kids = old_value or []
if isinstance(old_kids, str):
old_kids = json.loads(old_kids)
if not isinstance(old_kids, list):
old_kids = []
new_kids = new_value or []
if isinstance(new_kids, str):
new_kids = json.loads(new_kids)
if not isinstance(new_kids, list):
new_kids = []
# Merge by name
merged_kids = {k["name"]: k for k in old_kids if isinstance(k, dict) and "name" in k}
for nk in new_kids:
if not isinstance(nk, dict) or "name" not in nk:
continue
name = nk["name"]
if name in merged_kids:
# Update existing kid with new non-empty fields
for k, v in nk.items():
if v not in [None, "", []]:
merged_kids[name][k] = v
else:
merged_kids[name] = nk
clean_profile[field] = json.dumps(list(merged_kids.values()))
except Exception as e:
print(f"⚠️ Could not merge kids info: {new_value}, error={e}")
clean_profile[field] = old_value
# ---- Handle list-type fields ----
elif field in ["hobbies", "interests", "sports_played", "sports_watched", "language_spoken"]:
old_list = old_value or []
if isinstance(old_list, str):
old_list = [old_list]
if isinstance(new_value, str):
new_list = [new_value]
elif isinstance(new_value, list):
new_list = new_value
else:
new_list = []
merged = list({v.strip() for v in (old_list + new_list) if v})
clean_profile[field] = merged if merged else old_value
# ---- Handle simple fields ----
else:
if new_value not in [None, "", []]:
clean_profile[field] = str(new_value)
else:
clean_profile[field] = old_value
# Always set ID
clean_profile["id"] = user_id
# Upsert into Supabase
supabase.table("user_profiles").upsert(clean_profile).execute()
#use in classify_chat.py to update countries
def update_country(role: str, country_name: str, user_id: str) -> bool:
"""Update living_country or origin_country in Supabase."""
field = "living_country" if role == "living" else "origin_country"
try:
supabase.table("user_profiles").update({field: country_name}).eq("id", user_id).execute()
print(f"✅ Updated {field} for {user_id}{country_name}")
return True
except Exception as e:
print(f"❌ Failed to update {field} for {user_id}: {e}")
return False
### ENSURE USER ROW EXISTS (FK parent for history tables)
###############################################################
def ensure_user_row(user_id: str, email: str = "") -> None:
"""No-op.
Previously inserted into public.users as a supposed FK parent for the
history tables. That table does not exist (Postgrest returns PGRST205),
so this ran on every authenticated request, failed 100% of the time, and
added a wasted round-trip. The history tables work without it. Kept as a
no-op so the import in app.py stays valid.
"""
return None
### HELPER FOR IMPORTING AND EXPORTING HISORIES TO SUPABASE
###############################################################
def load_history_for_display(user_id: str):
"""Return flattened total history for UI display."""
total = _load_history("chat_history_total", user_id)
messages = []
for session in total["sessions"]:
for m in session["messages"]:
messages.append(m)
return messages
def _load_history(table: str, user_id: str):
"""Fetch messages JSON from Supabase, init if missing."""
resp = db_execute(supabase.table(table).select("messages").eq("id", user_id))
if resp.data:
return resp.data[0]["messages"]
init_state = {"sessions": []}
try:
db_execute(supabase.table(table).upsert({"id": user_id, "messages": init_state}))
except Exception as e:
print(f"⚠️ _load_history could not init {table} for {user_id}: {e}")
return init_state
def _save_history(table: str, messages: dict, user_id: str):
"""Save messages JSON back into Supabase."""
db_execute(supabase.table(table).upsert({"id": user_id, "messages": messages}))
### HELPER FOR DOWNLOADING FAISS FROM SUPABASE
###############################################################
def download_faiss_from_supabase(db_key: str, username: str = None) -> str:
"""
Download FAISS index (index.faiss + index.pkl if present) from Supabase Storage.
Bucket = 'Databases'
- db1/db2/db3 → Databases/shared/db1/
- db4+ → Databases/users/user_<username>/dbX/
"""
bucket_name = "Databases"
if db_key in ["db1", "db2", "db3","db8"]:
path_prefix = f"shared/{db_key}"
tmp_dir = f"/tmp/global_{db_key}"
else:
if not username:
raise ValueError(f"db_key {db_key} requires a username")
path_prefix = f"users/user_{username}/{db_key}"
tmp_dir = f"/tmp/{username}_{db_key}"
os.makedirs(tmp_dir, exist_ok=True, mode=0o700)
for filename in ["index.faiss", "index.pkl"]:
path = f"{path_prefix}/{filename}"
try:
resp = supabase.storage.from_(bucket_name).download(path)
if not resp:
print(f"[WARN] {filename} not found in bucket, skipping")
continue
except Exception as e:
print(f"[WARN] Failed to download {path} from {bucket_name}: {e}")
continue
with open(os.path.join(tmp_dir, filename), "wb") as f:
f.write(resp)
if not os.path.exists(os.path.join(tmp_dir, "index.faiss")):
raise FileNotFoundError(f"index.faiss missing in {bucket_name}/{path_prefix}")
return tmp_dir
### HELPER FOR db3 and db6 UPLOAD FAISS AND JSON TO SUPABASE
###############################################################
def upload_text(bucket: str, path: str, text: str):
"""
Upload plain text to Supabase Storage.
Overwrites existing file by removing it first if it exists.
"""
import traceback
#bucket = "Databases" # enforce correct bucket
try:
supabase.storage.from_(bucket).remove([path])
except Exception as e:
print(f"[INFO] Could not remove existing file {path} in {bucket}: {e}")
try:
supabase.storage.from_(bucket).upload(
path,
text.encode("utf-8"),
{"content-type": "text/plain"}
)
except Exception as e:
print(f"[ERROR] Upload failed → bucket={bucket}, path={path}")
traceback.print_exc()
raise
print(f"✅ Uploaded text file to {bucket}/{path}")
def upload_json(bucket: str, path: str, data: str):
"""
Upload JSON data to Supabase Storage.
Overwrites existing file by removing it first if it exists.
"""
import traceback
#bucket = "Databases"
try:
supabase.storage.from_(bucket).remove([path])
except Exception as e:
print(f"[INFO] Could not remove existing file {path} in {bucket}: {e}")
try:
supabase.storage.from_(bucket).upload(
path,
json.dumps(data, indent=2).encode("utf-8"),
{"content-type": "application/json"}
)
except Exception as e:
print(f"[ERROR] Upload failed → bucket={bucket}, path={path}")
traceback.print_exc()
raise
print(f"✅ Uploaded JSON file to {bucket}/{path}")
def save_faiss_to_supabase(db, db_key: str, username: str = None):
"""
Save FAISS index to Supabase Storage inside the 'Databases' bucket.
- db1/db2 → Databases/shared/db1/
- db3+ → Databases/users/user_<username>/dbX/
"""
import traceback
# Save locally first
tmp_dir = f"/tmp/{db_key}_upload"
os.makedirs(tmp_dir, exist_ok=True)
db.save_local(tmp_dir, index_name="index")
bucket_name = "Databases"
if db_key in ["db1", "db2", "db3","db8"]:
path_prefix = f"shared/{db_key}"
else:
if not username:
raise ValueError(f"db_key {db_key} requires username for user DBs")
path_prefix = f"users/user_{username}/{db_key}"
for filename in ["index.faiss", "index.pkl"]:
local_path = os.path.join(tmp_dir, filename)
if not os.path.exists(local_path):
print(f"[WARN] {filename} not found locally, skipping upload")
continue
try:
# Remove existing file first (prevents 409 Duplicate)
supabase.storage.from_(bucket_name).remove([f"{path_prefix}/{filename}"])
except Exception as e:
print(f"[INFO] Could not remove {filename} before upload: {e}")
try:
with open(local_path, "rb") as f:
supabase.storage.from_(bucket_name).upload(
f"{path_prefix}/{filename}",
f,
{"content-type": "application/octet-stream"}
)
except Exception as e:
print(f"[ERROR] Upload failed → bucket={bucket_name}, path={path_prefix}/{filename}")
traceback.print_exc()
raise
print(f"✅ Uploaded FAISS index to {bucket_name}/{path_prefix}")
# put this in main (same file where you have your Supabase client `sb`)
def create_ui_language_row(user_id: str, ui_lang: str) -> None:
"""Create/ensure a row in user_ui_language with initial_language=ui_lang and last_message_language='unknown'."""
supabase.table("user_ui_language").upsert(
{
"user_id": user_id,
"initial_language": ui_lang,
},
on_conflict="user_id",
).execute()
def get_initial_language(user_id: str, default: str = "en") -> str:
"""Return the user's initial_language from user_ui_language, or default."""
try:
res = (
supabase.table("user_ui_language")
.select("initial_language")
.eq("user_id", user_id)
.limit(1)
.execute()
)
rows = res.data or []
if not rows:
return default
lang = (rows[0].get("initial_language") or "").lower().strip()
if len(lang) == 2 and lang.isalpha():
return lang
return default
except Exception as e:
print(f"[get_initial_language] error: {e}")
return default
def get_last_message_language(user_id: str, default: str = "en") -> str:
"""Return the language used in the previous message exchange, or default."""
try:
res = (
supabase.table("user_ui_language")
.select("last_message_language")
.eq("user_id", user_id)
.limit(1)
.execute()
)
rows = res.data or []
if not rows:
return default
lang = (rows[0].get("last_message_language") or "").lower().strip()
if len(lang) == 2 and lang.isalpha():
return lang
return default
except Exception as e:
print(f"[get_last_message_language] error: {e}")
return default
def set_last_message_language(user_id: str, code_language: str) -> None:
if len(code_language) != 2 or not code_language.isalpha():
code_language = "en" # safety for DB CHECK constraint
# Recording the UI language is a non-critical side-effect. Use db_execute so a
# transient transport blip is retried, and never let a failure here abort the
# user's reply (this used to raise RemoteProtocolError up through run_chat_app).
try:
db_execute(
supabase.table("user_ui_language").upsert(
{"user_id": user_id, "last_message_language": code_language},
on_conflict="user_id",
)
)
except Exception as e:
print(f"[set_last_message_language] non-fatal write failed for {user_id}: {e}")
# --- JSON/TEXT DOWNLOAD HELPERS ---
def download_text(bucket: str, path: str) -> str:
"""Download a UTF-8 text file from Supabase Storage."""
data = supabase.storage.from_(bucket).download(path)
# supabase-py returns bytes
return data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else str(data)
def download_json(bucket: str, path: str):
"""Download a JSON file from Supabase Storage and parse to Python."""
txt = download_text(bucket, path)
return json.loads(txt)
def load_json(path: str):
"""
Flexible loader:
- Local files: 'style_examples.json' or '/abs/path/file.json'
- Supabase Storage via URI: 'supabase://<bucket>/<path/to/file.json>'
"""
# Supabase URI mode
if isinstance(path, str) and path.startswith("supabase://"):
# Example: supabase://Assets/style_examples.json
_, rest = path.split("://", 1)
parts = rest.split("/", 1)
bucket = parts[0]
key = parts[1] if len(parts) > 1 else ""
if not key:
raise ValueError("Supabase URI must include object path after bucket, e.g. supabase://Bucket/dir/file.json")
return download_json(bucket, key)
# def get_user_profile_by_username(username: str):
# """
# Look up user_profiles row by username and return (profile_row, user_id).
# Raises ValueError if no match is found.
# """
# resp = supabase.table("user_profiles").select("*").eq("username", username).limit(1).execute()
# rows = resp.data or []
# if not rows:
# raise ValueError(f"No user_profile found for username={username!r}")
# row = rows[0]
# user_id = row["id"]
# return row, user_id
def get_user_id_from_jwt(access_token: str) -> str:
"""
Given a Supabase access token (JWT from client),
ask Supabase who this user is, and return user.id (UUID).
"""
res = supabase.auth.get_user(access_token)
user = res.user
if not user or not getattr(user, "id", None):
raise Exception("Invalid Supabase JWT – cannot get user")
return user.id
def get_user_profile_by_id(user_id: str):
"""
Look up user_profiles row by Supabase Auth ID.
Returns (profile_row, username) or (None, None) if not found.
"""
resp = supabase.table("user_profiles").select("*").eq("id", user_id).limit(1).execute()
rows = resp.data or []
if not rows:
return None, None
row = rows[0]
username = row.get("username") or user_id
return row, username