// PolishingService.swift // OSGKeyboard ยท Shared // // Takes raw ASR transcript and runs it through the user's configured LLM // to produce polished, well-punctuated text. Falls back to the raw transcript // if the LLM call fails or times out. // // Cloud engine always runs the LLM polish step (settings no longer expose // off / transcribe). Local engine (`engineMode == "local"`) is ASR-only โ€” // the raw transcript is returned unchanged and cloud API settings are ignored. import Foundation public actor PolishingService { public enum PolishError: Error, Equatable { case noTranscript case timeout } private let store: AppGroupStore private let timeout: TimeInterval /// Optional injected client (mostly for testing). When nil we build /// one from `store.makeClient()` per call. private let injectedClient: LLMClient? /// Default `timeout` is `LLMClient.requestTimeout + 1` second so the /// safety-net `withThrowingTaskGroup` never wins the race against /// the URL request itself; if the request times out cleanly the /// network error reaches us first. The +1 is the single point of /// slack between the two clocks โ€” keep it here, not in `LLMClient`. public init( store: AppGroupStore = AppGroupStore(), client: LLMClient? = nil, timeout: TimeInterval? = nil ) { self.store = store self.injectedClient = client self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1) } public func polish(_ raw: String) async throws -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw PolishError.noTranscript } // Local engine: ASR-only โ€” no on-device or cloud polish. if store.engineMode == "local" { return trimmed } return try await polishRemote(trimmed) } private func polishRemote(_ trimmed: String) async throws -> String { let client = injectedClient ?? store.makeClient() let prompt = store.systemPrompt let budget = effectiveTimeout(for: trimmed) return try await withThrowingTaskGroup(of: String.self) { group in group.addTask { try await client.polish(trimmed, systemPrompt: prompt) } group.addTask { try await Task.sleep(nanoseconds: UInt64(budget * 1_000_000_000)) throw PolishError.timeout } let result = try await group.next()! group.cancelAll() return result } } /// Scale polish budget with transcript length (3-minute Flow utterances). private func effectiveTimeout(for text: String) -> TimeInterval { let scaled = timeout + (Double(text.count) / 200.0) * 2.0 return min(max(scaled, timeout), 120) } }