forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrecording_sessions.py
More file actions
331 lines (293 loc) · 12.2 KB
/
Copy pathrecording_sessions.py
File metadata and controls
331 lines (293 loc) · 12.2 KB
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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
"""Durable listen recording-session bindings and ordered lifecycle envelopes.
The user-scoped resource is the authority for mapping one recording session to
one conversation. It intentionally stores only routing metadata: never
transcript text, credentials, or WebSocket payloads. Conversation lifecycle
mutation remains owned by ``utils.conversations.lifecycle``; this adapter only
persists the recording identity and its outbound event sequence.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal, TypedDict
from google.cloud import firestore
from database import conversations as conversations_db
from database._client import get_firestore_client
RECORDING_SESSIONS_COLLECTION = 'recording_sessions'
CONVERSATIONS_COLLECTION = 'conversations'
RECORDING_SESSION_SCHEMA_VERSION = 1
LIFECYCLE_ENVELOPE_VERSION = 1
RecordingPhase = Literal['in_progress', 'processing', 'completed', 'failed', 'discarded']
_PHASE_ORDER: dict[str, int] = {
'in_progress': 0,
'processing': 1,
'completed': 2,
'failed': 2,
'discarded': 2,
}
_TERMINAL_PHASES = frozenset({'completed', 'failed', 'discarded'})
class RecordingSessionBinding(TypedDict):
recording_session_id: str
conversation_id: str
lifecycle_version: int
lifecycle_phase: str
lifecycle_sequence: int
mapping_conflict: bool
class RecordingSessionEvent(TypedDict):
recording_session_id: str
conversation_id: str
lifecycle_version: int
lifecycle_phase: str
lifecycle_sequence: int
accepted: bool
discard_reason: str | None
def _now() -> datetime:
return datetime.now(timezone.utc)
def _client(firestore_client: Any = None) -> Any:
return firestore_client if firestore_client is not None else get_firestore_client()
def _session_ref(client: Any, uid: str, recording_session_id: str) -> Any:
return (
client.collection('users')
.document(uid)
.collection(RECORDING_SESSIONS_COLLECTION)
.document(recording_session_id)
)
def _binding(data: dict[str, Any], recording_session_id: str, *, mapping_conflict: bool) -> RecordingSessionBinding:
return {
'recording_session_id': recording_session_id,
'conversation_id': str(data['conversation_id']),
'lifecycle_version': int(data.get('lifecycle_version') or LIFECYCLE_ENVELOPE_VERSION),
'lifecycle_phase': str(data.get('lifecycle_phase') or 'in_progress'),
'lifecycle_sequence': int(data.get('lifecycle_sequence') or 0),
'mapping_conflict': mapping_conflict,
}
def _create_or_get_recording_session_txn(
transaction: Any,
session_ref: Any,
uid: str,
recording_session_id: str,
proposed_conversation_id: str,
now: datetime,
) -> RecordingSessionBinding:
snapshot = session_ref.get(transaction=transaction)
if getattr(snapshot, 'exists', False):
current = snapshot.to_dict() or {}
if current.get('uid') != uid or current.get('recording_session_id') != recording_session_id:
raise ValueError('recording session identity does not match its document binding')
return _binding(
current,
recording_session_id,
mapping_conflict=current.get('conversation_id') != proposed_conversation_id,
)
session = {
'schema_version': RECORDING_SESSION_SCHEMA_VERSION,
'uid': uid,
'recording_session_id': recording_session_id,
'conversation_id': proposed_conversation_id,
'lifecycle_version': LIFECYCLE_ENVELOPE_VERSION,
'lifecycle_phase': 'in_progress',
'lifecycle_sequence': 0,
'created_at': now,
'updated_at': now,
}
transaction.create(session_ref, session)
return _binding(session, recording_session_id, mapping_conflict=False)
def create_or_get_recording_session(
uid: str,
recording_session_id: str,
proposed_conversation_id: str,
*,
firestore_client: Any = None,
) -> RecordingSessionBinding:
"""Atomically bind a session to exactly one canonical conversation ID."""
if not uid or not recording_session_id or not proposed_conversation_id:
raise ValueError('uid, recording_session_id, and proposed_conversation_id are required')
client = _client(firestore_client)
transaction = client.transaction()
transactional = firestore.transactional(_create_or_get_recording_session_txn)
return transactional(
transaction,
_session_ref(client, uid, recording_session_id),
uid,
recording_session_id,
proposed_conversation_id,
_now(),
)
def get_recording_session(
uid: str,
recording_session_id: str,
*,
firestore_client: Any = None,
) -> RecordingSessionBinding | None:
"""Read the canonical binding without proposing or mutating an identity."""
if not uid or not recording_session_id:
raise ValueError('uid and recording_session_id are required')
snapshot = _session_ref(_client(firestore_client), uid, recording_session_id).get()
if not getattr(snapshot, 'exists', False):
return None
data = snapshot.to_dict() or {}
if data.get('uid') != uid or data.get('recording_session_id') != recording_session_id:
raise ValueError('recording session identity does not match its document binding')
return _binding(data, recording_session_id, mapping_conflict=False)
def tombstone_and_delete_empty_conversation(
uid: str,
conversation_id: str,
recording_session_id: str | None,
*,
firestore_client: Any = None,
deleted_conversation: dict[str, Any] | None = None,
) -> bool:
"""Atomically delete an empty live row and terminalize its bound session.
Segment/photo writes set the conversation's durable ``has_content`` marker
in transactions on this same parent document. Firestore therefore retries
this transaction when a late content write wins, preventing cleanup from
deleting user data based on a stale empty read.
``deleted_conversation``, when supplied, receives the raw snapshot this
transaction actually deleted. Physical cleanup that has to reason about the
row's contents must read them from here rather than fetching the document
itself: by the time this returns the row is gone, and a fetch beforehand
would decide against a snapshot a concurrent write can still invalidate.
"""
client = _client(firestore_client)
conversation_ref = (
client.collection('users').document(uid).collection(CONVERSATIONS_COLLECTION).document(conversation_id)
)
session_ref = _session_ref(client, uid, recording_session_id) if recording_session_id else None
transaction = client.transaction()
@firestore.transactional
def _delete_empty(transaction: Any) -> bool:
snapshot = conversation_ref.get(transaction=transaction)
if not getattr(snapshot, 'exists', False):
return False
conversation = snapshot.to_dict() or {}
if (
conversation.get('status') != 'in_progress'
or conversation.get('discarded')
or conversations_db.raw_conversation_has_content(uid, conversation)
):
return False
if session_ref is not None:
session_snapshot = session_ref.get(transaction=transaction)
if getattr(session_snapshot, 'exists', False):
session = session_snapshot.to_dict() or {}
if (
session.get('uid') == uid
and session.get('recording_session_id') == recording_session_id
and session.get('conversation_id') == conversation_id
):
phase = str(session.get('lifecycle_phase') or 'in_progress')
if phase not in _TERMINAL_PHASES:
transaction.update(
session_ref,
{
'lifecycle_phase': 'discarded',
'lifecycle_sequence': int(session.get('lifecycle_sequence') or 0) + 1,
'updated_at': _now(),
},
)
if deleted_conversation is not None:
# A contended transaction re-runs this function, so publish the
# snapshot that belongs to the attempt that actually commits.
deleted_conversation.clear()
deleted_conversation.update(conversation)
transaction.delete(conversation_ref)
return True
return _delete_empty(transaction)
def _record_lifecycle_event_txn(
transaction: Any,
session_ref: Any,
recording_session_id: str,
conversation_id: str,
phase: RecordingPhase,
now: datetime,
) -> RecordingSessionEvent:
snapshot = session_ref.get(transaction=transaction)
if not getattr(snapshot, 'exists', False):
return {
'recording_session_id': recording_session_id,
'conversation_id': conversation_id,
'lifecycle_version': LIFECYCLE_ENVELOPE_VERSION,
'lifecycle_phase': phase,
'lifecycle_sequence': 0,
'accepted': False,
'discard_reason': 'missing_session',
}
current = snapshot.to_dict() or {}
bound_conversation_id = str(current.get('conversation_id') or '')
version = int(current.get('lifecycle_version') or LIFECYCLE_ENVELOPE_VERSION)
sequence = int(current.get('lifecycle_sequence') or 0)
current_phase = str(current.get('lifecycle_phase') or 'in_progress')
if bound_conversation_id != conversation_id:
return {
'recording_session_id': recording_session_id,
'conversation_id': bound_conversation_id,
'lifecycle_version': version,
'lifecycle_phase': current_phase,
'lifecycle_sequence': sequence,
'accepted': False,
'discard_reason': 'mapping_conflict',
}
if current_phase in _TERMINAL_PHASES and phase != current_phase:
return {
'recording_session_id': recording_session_id,
'conversation_id': bound_conversation_id,
'lifecycle_version': version,
'lifecycle_phase': current_phase,
'lifecycle_sequence': sequence,
'accepted': False,
'discard_reason': 'terminal_immutable',
}
if _PHASE_ORDER[phase] < _PHASE_ORDER.get(current_phase, -1):
return {
'recording_session_id': recording_session_id,
'conversation_id': bound_conversation_id,
'lifecycle_version': version,
'lifecycle_phase': current_phase,
'lifecycle_sequence': sequence,
'accepted': False,
'discard_reason': 'stale_event',
}
if phase == current_phase:
return {
'recording_session_id': recording_session_id,
'conversation_id': bound_conversation_id,
'lifecycle_version': version,
'lifecycle_phase': current_phase,
'lifecycle_sequence': sequence,
'accepted': True,
'discard_reason': None,
}
next_sequence = sequence + 1
transaction.update(
session_ref,
{'lifecycle_phase': phase, 'lifecycle_sequence': next_sequence, 'updated_at': now},
)
return {
'recording_session_id': recording_session_id,
'conversation_id': bound_conversation_id,
'lifecycle_version': version,
'lifecycle_phase': phase,
'lifecycle_sequence': next_sequence,
'accepted': True,
'discard_reason': None,
}
def record_lifecycle_event(
uid: str,
recording_session_id: str,
conversation_id: str,
phase: RecordingPhase,
*,
firestore_client: Any = None,
) -> RecordingSessionEvent:
"""Append a monotonic lifecycle envelope, rejecting stale or misbound events."""
if phase not in _PHASE_ORDER:
raise ValueError(f'unsupported recording lifecycle phase: {phase}')
client = _client(firestore_client)
transaction = client.transaction()
transactional = firestore.transactional(_record_lifecycle_event_txn)
return transactional(
transaction,
_session_ref(client, uid, recording_session_id),
recording_session_id,
conversation_id,
phase,
_now(),
)