Skip to content

Commit 1ee0d4a

Browse files
authored
fix(desktop): replace from-segments with force-process on stop (#6356)
1 parent 4939666 commit 1ee0d4a

7 files changed

Lines changed: 372 additions & 198 deletions

File tree

desktop/Backend-Rust/src/routes/conversations.rs

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,28 @@ async fn get_conversations_count(
157157
}
158158

159159
/// POST /v1/conversations/from-segments - Create conversation from transcript
160-
/// Copied from Python create_conversation_from_segments
160+
/// DEPRECATED: Desktop now uses Python POST /v1/conversations (force-process) instead.
161+
/// This endpoint will return 410 Gone after 24 hours from deploy.
162+
/// See: https://github.com/BasedHardware/omi/issues/6355
161163
async fn create_conversation_from_segments(
162164
State(state): State<AppState>,
163165
user: AuthUser,
164166
Json(request): Json<CreateConversationRequest>,
165167
) -> Result<Json<CreateConversationResponse>, (StatusCode, String)> {
166-
tracing::info!(
167-
"Creating conversation for user {} from {} segments",
168+
// Deprecation: return 410 Gone after 24h from deploy
169+
if is_from_segments_deprecated() {
170+
tracing::warn!(
171+
"from-segments endpoint expired for user {} (deprecated)",
172+
user.uid,
173+
);
174+
return Err((
175+
StatusCode::GONE,
176+
"This endpoint is deprecated. Desktop app now uses Python POST /v1/conversations.".to_string(),
177+
));
178+
}
179+
180+
tracing::warn!(
181+
"DEPRECATED: Creating conversation for user {} from {} segments — use Python POST /v1/conversations instead",
168182
user.uid,
169183
request.transcript_segments.len()
170184
);
@@ -1126,6 +1140,21 @@ async fn get_shared_conversation(
11261140
Ok(Json(response))
11271141
}
11281142

1143+
/// Check if the from-segments endpoint has been deprecated (>24h since DEPRECATION_TIMESTAMP).
1144+
fn is_from_segments_deprecated() -> bool {
1145+
is_from_segments_deprecated_at(chrono::Utc::now().timestamp())
1146+
}
1147+
1148+
/// Testable version: check deprecation at a given timestamp.
1149+
fn is_from_segments_deprecated_at(now: i64) -> bool {
1150+
if let Ok(ts) = std::env::var("DEPRECATION_TIMESTAMP") {
1151+
if let Ok(deploy_time) = ts.parse::<i64>() {
1152+
return now - deploy_time > 86400;
1153+
}
1154+
}
1155+
false
1156+
}
1157+
11291158
pub fn conversations_routes() -> Router<AppState> {
11301159
Router::new()
11311160
.route("/v1/conversations", get(get_conversations))
@@ -1158,3 +1187,41 @@ pub fn conversations_routes() -> Router<AppState> {
11581187
)
11591188
}
11601189

1190+
#[cfg(test)]
1191+
mod tests {
1192+
use super::*;
1193+
1194+
#[test]
1195+
fn test_deprecation_not_set() {
1196+
// No env var → not deprecated
1197+
std::env::remove_var("DEPRECATION_TIMESTAMP");
1198+
assert!(!is_from_segments_deprecated_at(1_000_000));
1199+
}
1200+
1201+
#[test]
1202+
fn test_deprecation_within_24h() {
1203+
let deploy_time: i64 = 1_700_000_000;
1204+
std::env::set_var("DEPRECATION_TIMESTAMP", deploy_time.to_string());
1205+
// 23h59m later → not deprecated
1206+
let now = deploy_time + 86399;
1207+
assert!(!is_from_segments_deprecated_at(now));
1208+
std::env::remove_var("DEPRECATION_TIMESTAMP");
1209+
}
1210+
1211+
#[test]
1212+
fn test_deprecation_after_24h() {
1213+
let deploy_time: i64 = 1_700_000_000;
1214+
std::env::set_var("DEPRECATION_TIMESTAMP", deploy_time.to_string());
1215+
// 24h + 1s later → deprecated
1216+
let now = deploy_time + 86401;
1217+
assert!(is_from_segments_deprecated_at(now));
1218+
std::env::remove_var("DEPRECATION_TIMESTAMP");
1219+
}
1220+
1221+
#[test]
1222+
fn test_deprecation_invalid_value() {
1223+
std::env::set_var("DEPRECATION_TIMESTAMP", "not-a-number");
1224+
assert!(!is_from_segments_deprecated_at(1_000_000));
1225+
std::env::remove_var("DEPRECATION_TIMESTAMP");
1226+
}
1227+
}

desktop/Desktop/Sources/APIClient.swift

Lines changed: 23 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,89 +1191,35 @@ struct ServerMemory: Codable, Identifiable {
11911191
}
11921192
}
11931193

1194-
// MARK: - Create Conversation API
1194+
// MARK: - Force Process Conversation API
11951195

11961196
extension APIClient {
11971197

1198-
/// Request model for creating a conversation from transcript segments
1199-
struct CreateConversationFromSegmentsRequest: Encodable {
1200-
let transcriptSegments: [TranscriptSegmentRequest]
1201-
let source: String
1202-
let startedAt: String
1203-
let finishedAt: String
1204-
let language: String
1205-
let timezone: String
1206-
let inputDeviceName: String?
1207-
1208-
enum CodingKeys: String, CodingKey {
1209-
case transcriptSegments = "transcript_segments"
1210-
case source
1211-
case startedAt = "started_at"
1212-
case finishedAt = "finished_at"
1213-
case language
1214-
case timezone
1215-
case inputDeviceName = "input_device_name"
1216-
}
1217-
}
1218-
1219-
struct TranscriptSegmentRequest: Encodable {
1220-
let id: String?
1221-
let text: String
1222-
let speaker: String
1223-
let speakerId: Int
1224-
let isUser: Bool
1225-
let personId: String?
1226-
let start: Double
1227-
let end: Double
1228-
1229-
enum CodingKeys: String, CodingKey {
1230-
case id, text, speaker
1231-
case speakerId = "speaker_id"
1232-
case isUser = "is_user"
1233-
case personId = "person_id"
1234-
case start, end
1235-
}
1236-
}
1237-
1238-
struct CreateConversationResponse: Decodable {
1239-
let id: String
1240-
let status: String
1241-
let discarded: Bool
1198+
/// Response from Python POST /v1/conversations (force-process)
1199+
struct ForceProcessConversationResponse: Decodable {
1200+
let conversation: ServerConversation
12421201
}
12431202

1244-
/// Creates a conversation from transcript segments
1245-
/// Endpoint: POST /v1/conversations/from-segments (local backend)
1246-
/// - Parameters:
1247-
/// - segments: Transcript segments to include
1248-
/// - startedAt: When the recording started
1249-
/// - finishedAt: When the recording finished
1250-
/// - source: Source of the conversation (e.g., "desktop", "omi", "bee")
1251-
/// - language: Language code for transcription
1252-
/// - timezone: User's timezone
1253-
/// - inputDeviceName: Name of the input device (microphone or BLE device)
1254-
func createConversationFromSegments(
1255-
segments: [TranscriptSegmentRequest],
1256-
startedAt: Date,
1257-
finishedAt: Date,
1258-
source: ConversationSource = .desktop,
1259-
language: String = "en",
1260-
timezone: String = "UTC",
1261-
inputDeviceName: String? = nil
1262-
) async throws -> CreateConversationResponse {
1263-
let formatter = ISO8601DateFormatter()
1264-
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1265-
1266-
let request = CreateConversationFromSegmentsRequest(
1267-
transcriptSegments: segments,
1268-
source: source.rawValue,
1269-
startedAt: formatter.string(from: startedAt),
1270-
finishedAt: formatter.string(from: finishedAt),
1271-
language: language,
1272-
timezone: timezone,
1273-
inputDeviceName: inputDeviceName
1274-
)
1203+
/// Force-process the current in-progress conversation on the Python backend.
1204+
/// Endpoint: POST /v1/conversations (Python backend)
1205+
/// This is the same endpoint the mobile app uses when stopping phone mic recording.
1206+
/// The Python backend finds the in-progress conversation via Redis and processes it.
1207+
/// Returns the processed conversation on success, nil on 404 (already processed).
1208+
/// Throws on other errors.
1209+
func forceProcessConversation() async throws -> ServerConversation? {
1210+
struct EmptyBody: Encodable {}
12751211

1276-
return try await post("v1/conversations/from-segments", body: request)
1212+
do {
1213+
let response: ForceProcessConversationResponse = try await post(
1214+
"v1/conversations",
1215+
body: EmptyBody(),
1216+
customBaseURL: pythonBackendURL
1217+
)
1218+
return response.conversation
1219+
} catch APIError.httpError(let statusCode) where statusCode == 404 {
1220+
// 404 = no in-progress conversation found — WS close handler already processed it
1221+
return nil
1222+
}
12771223
}
12781224
}
12791225

desktop/Desktop/Sources/AppState.swift

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,17 @@ enum FinishConversationResult {
2626

2727
@MainActor
2828
class AppState: ObservableObject {
29+
/// Weak reference to the current AppState instance, set on init.
30+
/// Used by background services (e.g. TranscriptionRetryService) to check recording state.
31+
static weak var current: AppState?
32+
2933
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding = false
3034

3135
// Transcription state
3236
@Published var isTranscribing = false
37+
/// Monotonically increasing counter — incremented each time a new recording starts.
38+
/// Used to detect if a new recording began during the post-stop force-process delay.
39+
private(set) var recordingGeneration: UInt64 = 0
3340
@Published var isSavingConversation = false
3441
// currentTranscript is internal-only (not observed by views), so no @Published needed
3542
private var currentTranscript: String = ""
@@ -195,6 +202,9 @@ class AppState: ObservableObject {
195202
private var bluetoothStateCancellable: AnyCancellable?
196203

197204
init() {
205+
// Register as the current instance so background services can check recording state
206+
AppState.current = self
207+
198208
// Load API key from environment or .env file
199209
loadEnvironment()
200210

@@ -1331,6 +1341,7 @@ class AppState: ObservableObject {
13311341
)
13321342

13331343
isTranscribing = true
1344+
recordingGeneration &+= 1
13341345
AssistantSettings.shared.transcriptionEnabled = true
13351346
audioSource = effectiveSource
13361347
currentTranscript = ""
@@ -1538,22 +1549,89 @@ class AppState: ObservableObject {
15381549

15391550
/// Stop real-time transcription
15401551
/// The Python backend handles conversation lifecycle automatically — disconnecting the WebSocket
1541-
/// triggers conversation processing on the backend side.
1552+
/// triggers conversation processing on the backend side. We also call force-process to ensure
1553+
/// the conversation is finalized, preventing the retry service from creating duplicates.
15421554
func stopTranscription() {
1555+
// Capture session metadata BEFORE clearing state (clearTranscriptionState sets sessionId to nil)
1556+
let capturedSessionId = currentSessionId
1557+
let capturedStartTime = recordingStartTime
1558+
let generationAtStop = recordingGeneration
1559+
15431560
stopAudioCapture()
15441561
clearTranscriptionState()
15451562

1546-
// Backend processes the conversation when WebSocket disconnects.
1547-
// The local session stays as pendingUpload — the retry service will either:
1548-
// 1. Find the conversation on the backend (duplicate check) and mark it completed, or
1549-
// 2. Re-upload if backend processing failed (recovery path).
1550-
// We don't optimistically mark as completed because that orphans the session if backend fails.
1563+
// After WS close, the Python backend processes the conversation automatically.
1564+
// Call force-process to ensure finalization and get the backend conversation ID.
1565+
// This prevents the retry service from picking up the pendingUpload session.
15511566
Task {
1552-
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5s for backend to process
1567+
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3s for backend to process after WS close
1568+
1569+
// If a new recording started during the delay, skip force-process — it would
1570+
// finalize the NEW conversation instead of the one we just stopped.
1571+
// The retry service will reconcile the old session by timestamp matching.
1572+
guard self.recordingGeneration == generationAtStop else {
1573+
log("Transcription: New recording started during delay, skipping force-process for session \(capturedSessionId.map(String.init) ?? "nil")")
1574+
return
1575+
}
1576+
1577+
do {
1578+
if let conversation = try await APIClient.shared.forceProcessConversation() {
1579+
// Validate the returned conversation matches the session we just stopped
1580+
if let sessionId = capturedSessionId, let startTime = capturedStartTime,
1581+
let convStarted = conversation.startedAt,
1582+
abs(convStarted.timeIntervalSince(startTime)) < 10,
1583+
conversation.source == .desktop {
1584+
try? await TranscriptionStorage.shared.markSessionCompleted(
1585+
id: sessionId, backendId: conversation.id)
1586+
log("Transcription: Force-processed conversation \(conversation.id), session \(sessionId) completed")
1587+
} else if let sessionId = capturedSessionId, let startTime = capturedStartTime {
1588+
// Force-process returned a different conversation — fall back to reconciliation
1589+
log("Transcription: Force-processed conversation \(conversation.id) does not match session \(sessionId), reconciling by timestamp")
1590+
await reconcileSession(sessionId: sessionId, startTime: startTime)
1591+
}
1592+
} else {
1593+
// 404: No in-progress conversation — WS close handler already processed it.
1594+
// Reconcile by checking if a matching conversation exists on the backend.
1595+
if let sessionId = capturedSessionId, let startTime = capturedStartTime {
1596+
await reconcileSession(sessionId: sessionId, startTime: startTime)
1597+
}
1598+
}
1599+
} catch {
1600+
// Other error — leave session as pendingUpload for retry service to reconcile
1601+
logError("Transcription: Force-process failed, retry service will reconcile", error: error)
1602+
}
1603+
15531604
await loadConversations()
15541605
}
15551606
}
15561607

1608+
/// Reconcile a local session by checking if a matching conversation exists on the backend.
1609+
/// If found, marks the session as completed. Otherwise leaves it as pendingUpload for retry.
1610+
private func reconcileSession(sessionId: Int64, startTime: Date) async {
1611+
do {
1612+
let conversations = try await APIClient.shared.getConversations(
1613+
limit: 5,
1614+
includeDiscarded: true,
1615+
startDate: startTime.addingTimeInterval(-5),
1616+
endDate: Date().addingTimeInterval(5)
1617+
)
1618+
if let match = conversations.first(where: { conv in
1619+
guard let convStarted = conv.startedAt else { return false }
1620+
// Must be a desktop conversation with matching start time
1621+
guard conv.source == .desktop else { return false }
1622+
return abs(convStarted.timeIntervalSince(startTime)) < 10
1623+
}) {
1624+
try await TranscriptionStorage.shared.markSessionCompleted(
1625+
id: sessionId, backendId: match.id)
1626+
log("Transcription: Reconciled session \(sessionId) → backend conversation \(match.id)")
1627+
} else {
1628+
log("Transcription: No matching backend conversation found for session \(sessionId), leaving for retry")
1629+
}
1630+
} catch {
1631+
logError("Transcription: Reconciliation failed for session \(sessionId)", error: error)
1632+
}
1633+
}
1634+
15571635
/// Finish the current conversation and keep recording for a new one.
15581636
/// Disconnects the WebSocket (triggers backend conversation processing) then reconnects.
15591637
func finishConversation() async -> FinishConversationResult {

desktop/Desktop/Sources/Rewind/Core/TranscriptionStorage.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ actor TranscriptionStorage {
117117
log("TranscriptionStorage: Completed session \(id) (backendId: \(backendId))")
118118
}
119119

120-
/// Mark session as failed with error
120+
/// Mark session as failed with error.
121+
/// No-op if the session is already completed (prevents race with concurrent completion).
121122
func markSessionFailed(id: Int64, error: String) async throws {
122123
let db = try await ensureInitialized()
123124

@@ -126,6 +127,12 @@ actor TranscriptionStorage {
126127
throw TranscriptionStorageError.sessionNotFound
127128
}
128129

130+
// Don't regress a completed session back to failed
131+
guard record.status != .completed else {
132+
log("TranscriptionStorage: Skipping markSessionFailed for already-completed session \(id)")
133+
return
134+
}
135+
129136
record.status = .failed
130137
record.lastError = error
131138
record.updatedAt = Date()
@@ -135,7 +142,8 @@ actor TranscriptionStorage {
135142
log("TranscriptionStorage: Failed session \(id) (error: \(error))")
136143
}
137144

138-
/// Increment retry count for a session
145+
/// Increment retry count for a session.
146+
/// No-op if the session is already completed (prevents race with concurrent completion).
139147
func incrementRetryCount(id: Int64) async throws {
140148
let db = try await ensureInitialized()
141149

@@ -144,6 +152,12 @@ actor TranscriptionStorage {
144152
throw TranscriptionStorageError.sessionNotFound
145153
}
146154

155+
// Don't modify a completed session
156+
guard record.status != .completed else {
157+
log("TranscriptionStorage: Skipping incrementRetryCount for already-completed session \(id)")
158+
return
159+
}
160+
147161
record.retryCount += 1
148162
record.updatedAt = Date()
149163
try record.update(database)

0 commit comments

Comments
 (0)