df1c5ff32c
Replace MLX GPU inference with CoreML bundles so transcription continues while the host app is backgrounded. Adds model download and warm-up, vendored Qwen3Speech, and updates onboarding, settings, and copy for the ~1.6 GB CoreML package (iOS 18+).
79 lines
2.8 KiB
Swift
79 lines
2.8 KiB
Swift
// 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)
|
|
}
|
|
}
|