speechkid-api / data_store.py
yoavzamir's picture
data_store: stamp model_version into every evaluation record
fe1ec94
Raw
History Blame Contribute Delete
9.93 kB
"""
Supabase data storage for SpeechKid evaluations.
Stores:
- Recording WAV files → private 'recordings' bucket (per-user folders)
- Evaluation results → 'evaluations' table (RLS-protected per user)
- Server logs → captured during scoring, saved per evaluation
Writes use the service_role key (server-to-server).
Reads (dashboard) use the user's token + anon key so RLS filters automatically.
"""
import os
import threading
from datetime import date, datetime, timedelta, timezone
# Bump this whenever the scoring logic/thresholds change. Stored with every
# evaluation so we can later tell which model version produced each label —
# essential for measuring accuracy improvements as the model evolves.
MODEL_VERSION = "2026.06.14-shk-wetness-dormant"
_client = None
def _get_client():
"""Lazy-init Supabase client (service_role). Returns None if not configured."""
global _client
if _client is not None:
return _client
url = os.environ.get("SUPABASE_URL")
key = os.environ.get("SUPABASE_KEY")
if not url or not key:
print("[DATA] Supabase not configured — SUPABASE_URL or SUPABASE_KEY missing")
return None
from supabase import create_client
_client = create_client(url, key)
print("[DATA] Supabase client initialized")
return _client
def _get_user_client(token: str):
"""
Create a Supabase client scoped to a specific user.
Uses the anon key as the API key and the user's access token for
Authorization. This way RLS policies filter by auth.uid() automatically —
the user can only see their own data without any server-side filtering.
"""
url = os.environ.get("SUPABASE_URL")
anon_key = os.environ.get("SUPABASE_ANON_KEY")
if not url or not anon_key:
print("[DATA] SUPABASE_ANON_KEY not configured — cannot create user client")
return None
from supabase import create_client
client = create_client(url, anon_key)
client.postgrest.auth(token)
return client
def extract_user_id(token: str) -> str:
"""
Verify a Supabase access token and extract the user_id.
Uses the Supabase Auth API (via service_role client) to cryptographically
verify the token — not just base64-decoding it. This ensures the token
was actually issued by Supabase and hasn't been tampered with.
Returns None if no token provided or verification fails.
"""
if not token:
return None
try:
client = _get_client()
if client is None:
return None
response = client.auth.get_user(token)
user_id = response.user.id
print(f"[AUTH] User verified: {user_id[:8]}...")
return user_id
except Exception as e:
print(f"[AUTH] Token verification failed: {e}")
return None
def get_user_dashboard(token: str) -> dict:
"""
Query the user's evaluations and compute dashboard stats.
Uses the user's token + anon key so RLS filters by user_id automatically.
Returns dict with total_sessions, total_words, avg_score, streak_days,
and recent_evaluations.
"""
client = _get_user_client(token)
if client is None:
return None
# Fetch all evaluations for this user (RLS filters automatically)
response = (
client.table("evaluations")
.select("score, created_at, word, status")
.order("created_at", desc=True)
.execute()
)
rows = response.data or []
if not rows:
return {
"total_sessions": 0,
"total_words": 0,
"avg_score": 0,
"streak_days": 0,
"recent_evaluations": [],
}
# --- Total words (total evaluations) ---
total_words = len(rows)
# --- Average score ---
scores = [r["score"] for r in rows if r.get("score") is not None]
avg_score = round(sum(scores) / len(scores)) if scores else 0
# --- Distinct practice dates ---
practice_dates = set()
for r in rows:
try:
dt = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
practice_dates.add(dt.date())
except (ValueError, TypeError):
pass
total_sessions = len(practice_dates)
# --- Streak: consecutive days counting back from today ---
today = date.today()
streak = 0
check_date = today
while check_date in practice_dates:
streak += 1
check_date -= timedelta(days=1)
# --- Recent evaluations (last 20) ---
recent = []
for r in rows[:20]:
score_val = r.get("score", 0)
recent.append({
"word": r.get("word", ""),
"score": score_val,
"status": "החלצה" if score_val >= 70 else "בוש הסנ",
"created_at": r.get("created_at", ""),
})
return {
"total_sessions": total_sessions,
"total_words": total_words,
"avg_score": avg_score,
"streak_days": streak,
"recent_evaluations": recent,
}
def get_user_progress(token: str) -> dict:
"""
Return the user's level progress.
unlocked_level = highest level where passed=true, plus 1 (minimum 1).
level_stars = all rows for this user ordered by level.
"""
client = _get_user_client(token)
if client is None:
return None
response = (
client.table("user_progress")
.select("level, stars, passed, word_count")
.order("level")
.execute()
)
rows = response.data or []
passed_levels = [r["level"] for r in rows if r.get("passed")]
unlocked_level = max(passed_levels) + 1 if passed_levels else 1
level_stars = [
{
"level": r["level"],
"stars": r.get("stars", 0) or 0,
"passed": bool(r.get("passed", False)),
"word_count": r.get("word_count", 0) or 0,
}
for r in rows
]
return {"unlocked_level": unlocked_level, "level_stars": level_stars}
def save_user_progress(token: str, level: int, stars: int,
passed: bool, word_count: int) -> bool:
"""
Upsert a user_progress row with merge semantics:
- stars = max(old, new)
- passed = old OR new (once passed, stays passed)
- word_count = max(old, new)
Uses the user's token so RLS scopes the write to their own rows
and auth.uid() populates user_id via the column default.
Returns True on success, False on failure.
"""
client = _get_user_client(token)
if client is None:
return False
existing = (
client.table("user_progress")
.select("stars, passed, word_count")
.eq("level", level)
.limit(1)
.execute()
)
rows = existing.data or []
if rows:
old = rows[0]
merged = {
"stars": max(old.get("stars", 0) or 0, stars),
"passed": bool(old.get("passed", False)) or bool(passed),
"word_count": max(old.get("word_count", 0) or 0, word_count),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
client.table("user_progress").update(merged).eq("level", level).execute()
else:
client.table("user_progress").insert({
"level": level,
"stars": stars,
"passed": bool(passed),
"word_count": word_count,
}).execute()
return True
def save_evaluation(
evaluation_id: str,
word: str,
result: dict,
wav_data: bytes,
server_logs: str,
processing_ms: int,
user_id: str = None,
):
"""
Save a complete evaluation to Supabase in a background thread.
Uploads the recording WAV to the private 'recordings' bucket
(organized by user_id for RLS), then inserts the evaluation record.
Runs in a daemon thread so the API response is not delayed.
Failures are logged but never propagate to the caller.
"""
def _save():
try:
client = _get_client()
if client is None:
return
# --- Upload recording to private storage bucket ---
folder = user_id or "anonymous"
storage_path = f"{folder}/{evaluation_id}.wav"
try:
client.storage.from_("recordings").upload(
storage_path,
wav_data,
file_options={"content-type": "audio/wav"},
)
print(f"[DATA] Recording uploaded: {storage_path}")
except Exception as e:
print(f"[DATA] Recording upload failed: {e}")
storage_path = None
# --- Insert evaluation record ---
# Stamp the model version into the details JSON (schema-safe — no
# DB migration needed) so every recording is traceable to the
# pipeline that scored it.
details = dict(result.get("details", {}))
details["model_version"] = MODEL_VERSION
record = {
"id": evaluation_id,
"word": word,
"score": result.get("score", 0),
"status": result.get("status", "ERROR"),
"diagnosis": result.get("diagnosis", "UNKNOWN"),
"feedback": result.get("feedback", ""),
"details": details,
"evidence": result.get("evidence", {}),
"recording_path": storage_path,
"server_logs": server_logs,
"processing_ms": processing_ms,
"user_id": user_id,
}
client.table("evaluations").insert(record).execute()
print(f"[DATA] Evaluation saved: {evaluation_id} (user={folder[:8]}...)")
except Exception as e:
print(f"[DATA] Failed to save evaluation: {e}")
thread = threading.Thread(target=_save, daemon=True)
thread.start()