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()