Spaces:
Sleeping
Sleeping
File size: 9,899 Bytes
8f0c113 ef8a11d 8f0c113 ef8a11d a927b9c ef8a11d a927b9c ef8a11d 8f0c113 3721257 8f0c113 c488f66 210f9f6 8f0c113 737ff6a 8f0c113 a927b9c 737ff6a 8f0c113 ef8a11d 82b2e56 ef8a11d 8f0c113 f2e5f75 8f0c113 a927b9c 737ff6a 9a7ec08 ef8a11d 8f0c113 12dc286 9b5f12c 274b7fb 9b5f12c 274b7fb 9b5f12c 274b7fb 9b5f12c 274b7fb 9b5f12c 737ff6a 9b5f12c a927b9c 9b5f12c 274b7fb a927b9c 737ff6a a927b9c 9b5f12c ef8a11d 9b5f12c 210f9f6 9b5f12c 274b7fb 9b5f12c 53163d6 ef8a11d 53163d6 274b7fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | """Supabase-backed data layer for Transcriber.
All persistence goes through this module so main.py never touches the DB driver
directly. The backend authenticates with the Supabase SERVICE ROLE key (bypasses
RLS) and must only ever run on a trusted machine.
"""
import json
import os
import uuid
from datetime import datetime, timezone
from typing import Optional
from supabase import create_client, Client
TABLE = "saved_reels"
FAILURE_STATUSES = frozenset({"failed", "cookies_expired", "unsupported_format"})
_client: Optional[Client] = None
def get_client() -> Client:
"""Lazily create the Supabase client so importing db.py never needs creds."""
global _client
if _client is None:
url = os.getenv("SUPABASE_URL")
key = os.getenv("SUPABASE_SERVICE_KEY")
if not url or not key:
raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_KEY must be set.")
_client = create_client(url, key)
return _client
def _chunked(seq: list, size: int):
"""Yield successive size-length slices of a sequence."""
for i in range(0, len(seq), size):
yield seq[i:i + size]
def _select_newest_first(columns: str, limit: Optional[int] = None, refine=None) -> list:
"""Select rows newest-first, paging past PostgREST's per-request row cap.
refine, if given, adds filters to each page's query."""
rows = []
while limit is None or len(rows) < limit:
last = len(rows) + 999 if limit is None else limit - 1
query = get_client().table(TABLE).select(columns)
if refine:
query = refine(query)
page = (query.order("created_at", desc=True).order("id")
.range(len(rows), last).execute().data or [])
if not page:
break
rows.extend(page)
return rows
def row_to_record(r: dict) -> dict:
"""Map a raw DB row to the API record shape the frontend expects."""
ej = r.get("extracted_json")
if isinstance(ej, str):
try:
ej = json.loads(ej)
except (json.JSONDecodeError, TypeError):
ej = {}
return {
"id": r["id"],
"url": r.get("url"),
"title": r.get("title"),
"raw_transcript": r.get("raw_transcript"),
"post_caption": r.get("post_caption"),
"extracted_json": ej,
"created_at": r.get("created_at"),
"cluster": r.get("cluster") or "Unclustered",
"status": r.get("status") or "done",
"error": r.get("error"),
}
def get_reel_by_url(url: str) -> Optional[dict]:
res = get_client().table(TABLE).select("*").eq("url", url).limit(1).execute()
rows = res.data or []
return row_to_record(rows[0]) if rows else None
def insert_reel(url, title, raw_transcript, post_caption, extracted_json,
status="done", source=None, embedding=None, cluster=None) -> dict:
row = {
"id": str(uuid.uuid4()),
"url": url,
"title": title,
"raw_transcript": raw_transcript,
"post_caption": post_caption,
"extracted_json": extracted_json, # dict -> jsonb
"created_at": datetime.now(timezone.utc).isoformat(),
"status": status,
"source": source,
}
if embedding is not None:
row["embedding"] = embedding # list -> pgvector
if cluster is not None:
row["cluster"] = cluster
get_client().table(TABLE).insert(row).execute()
return row_to_record(row)
def statuses_for_urls(urls: list) -> list:
"""Return [{url, status, error}] for whichever of the given URLs exist."""
rows = []
for chunk in _chunked(urls, 100):
res = get_client().table(TABLE).select("url", "status", "error").in_("url", chunk).execute()
rows.extend(res.data or [])
return rows
def insert_pending_reels(reels: list, source: str) -> None:
"""Enqueue parsed [{url, title, caption}] reels as pending rows for the worker."""
rows = [
{
"id": str(uuid.uuid4()),
"url": r["url"],
"title": r["title"] or f"Reel ({r['url'].split('/reel/')[-1].replace('/', '')})",
"raw_transcript": "",
"post_caption": r["caption"],
"extracted_json": {},
"created_at": datetime.now(timezone.utc).isoformat(),
"status": "pending",
"source": source,
}
for r in reels
]
for chunk in _chunked(rows, 50):
get_client().table(TABLE).insert(chunk).execute()
def reels_for_clustering() -> list:
"""Every row as {id, extracted_json, embedding}, with embedding as a list of floats or None."""
rows = []
for r in _select_newest_first("id, extracted_json, embedding"):
embedding = r.get("embedding")
if isinstance(embedding, str): # PostgREST returns pgvector as text: "[0.1,0.2,...]"
embedding = json.loads(embedding)
rows.append({"id": r["id"], "extracted_json": row_to_record(r)["extracted_json"], "embedding": embedding})
return rows
def reels_missing_embedding() -> list:
"""Rows with no search embedding yet, as [{id, extracted_json}]."""
return _select_newest_first("id, extracted_json", refine=lambda q: q.is_("embedding", "null"))
def set_embedding(reel_id: str, embedding: list) -> None:
get_client().table(TABLE).update({"embedding": embedding}).eq("id", reel_id).execute()
def nearest_cluster(embedding: list, min_similarity: float) -> Optional[str]:
"""Cluster of the most similar already-clustered reel at or above min_similarity, else None."""
c = get_client()
matches = c.rpc("match_reels", {"query_embedding": embedding, "match_threshold": min_similarity,
"match_count": 10}).execute().data or []
ids = [m["id"] for m in matches] # closest first
if not ids:
return None
rows = c.table(TABLE).select("id, cluster").in_("id", ids).execute().data or []
clusters = {r["id"]: r["cluster"] for r in rows if r.get("cluster")}
return next((clusters[i] for i in ids if i in clusters), None)
def set_cluster(reel_ids: list, cluster: Optional[str]) -> None:
for chunk in _chunked(reel_ids, 100):
get_client().table(TABLE).update({"cluster": cluster}).in_("id", chunk).execute()
def delete_reel(reel_id: str) -> bool:
"""Delete a reel by id. Returns True if a row was removed, False if none matched."""
res = get_client().table(TABLE).delete().eq("id", reel_id).execute()
return bool(res.data)
def claim_next_pending() -> Optional[dict]:
"""Atomically claim the oldest pending reel: flip it to 'processing' and
return the raw row. Returns None if the queue is empty or another worker
won the claim."""
c = get_client()
now = datetime.now(timezone.utc).isoformat()
res = (c.table(TABLE).select("*")
.eq("status", "pending")
.or_(f"next_attempt_at.is.null,next_attempt_at.lte.{now}")
.order("created_at").limit(1).execute())
rows = res.data or []
if not rows:
return None
row = rows[0]
attempt_count = int(row.get("attempt_count") or 0) + 1
payload = {
"status": "processing",
"processing_started_at": now,
"next_attempt_at": None,
"attempt_count": attempt_count,
}
upd = (c.table(TABLE).update(payload)
.eq("id", row["id"]).eq("status", "pending").execute())
if not upd.data:
return None # lost the race
return {**row, **payload}
def update_reel_result(reel_id: str, title: str, raw_transcript, post_caption,
extracted_json, status: str = "done", embedding=None, cluster=None) -> None:
"""Write pipeline results back onto an existing (claimed) row."""
payload = {
"title": title,
"raw_transcript": raw_transcript,
"post_caption": post_caption,
"extracted_json": extracted_json,
"status": status,
"error": None,
"processing_started_at": None,
"next_attempt_at": None,
}
if embedding is not None:
payload["embedding"] = embedding
if cluster is not None:
payload["cluster"] = cluster
get_client().table(TABLE).update(payload).eq("id", reel_id).execute()
def mark_failed(reel_id: str, error, status: str = "failed") -> None:
"""Mark a claimed row with a failure status, recording a truncated error message."""
get_client().table(TABLE).update({
"status": status,
"error": str(error)[:500],
"processing_started_at": None,
"next_attempt_at": None,
}).eq("id", reel_id).execute()
def retry_reel(reel_id: str) -> bool:
"""Requeue a terminal failure and reset its retry budget."""
res = get_client().table(TABLE).update({
"status": "pending",
"error": None,
"processing_started_at": None,
"next_attempt_at": None,
"attempt_count": 0,
}).eq("id", reel_id).in_("status", list(FAILURE_STATUSES)).execute()
return bool(res.data)
def recover_stale_processing(stale_before: datetime) -> int:
"""Return abandoned claims older than stale_before to the queue."""
res = (get_client().table(TABLE).update({
"status": "pending",
"error": "Recovered stale processing claim",
"processing_started_at": None,
}).eq("status", "processing")
.lt("processing_started_at", stale_before.astimezone(timezone.utc).isoformat())
.execute())
return len(res.data or [])
def schedule_retry(reel_id: str, error: str, next_attempt_at: datetime) -> None:
"""Release a claim for a future retry while preserving its attempt count."""
get_client().table(TABLE).update({
"status": "pending",
"error": str(error)[:500],
"processing_started_at": None,
"next_attempt_at": next_attempt_at.astimezone(timezone.utc).isoformat(),
}).eq("id", reel_id).execute()
|