79be7384dd
13 items, 555-line diff, build + 15/15 tests green.
ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.
ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.
ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.
ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.
RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.
TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).
TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.
TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.
TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
73 lines
2.7 KiB
Swift
73 lines
2.7 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.
|
|
//
|
|
// Mode-aware: when `modeId == "off"` the service short-circuits and returns
|
|
// the trimmed input without touching the network. This is the runtime
|
|
// guarantee behind the keyboard's "Off · 关闭" mode.
|
|
|
|
import Foundation
|
|
|
|
public actor PolishingService {
|
|
|
|
public enum PolishError: Error, Equatable {
|
|
case noTranscript
|
|
case timeout
|
|
case modeOff
|
|
}
|
|
|
|
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 }
|
|
|
|
// Mode-aware short-circuit. When the user has selected "Off", the
|
|
// keyboard must never hit the network — we return the trimmed
|
|
// input as-is. This is the same value the view controller would
|
|
// produce if it skipped `polish()` entirely, but having the
|
|
// guarantee at the service layer means future call sites (CLI,
|
|
// tests, alternate keyboards) inherit it for free.
|
|
if store.modeId == "off" {
|
|
return trimmed
|
|
}
|
|
|
|
let client = injectedClient ?? store.makeClient()
|
|
let prompt = store.systemPrompt
|
|
|
|
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(self.timeout * 1_000_000_000))
|
|
throw PolishError.timeout
|
|
}
|
|
let result = try await group.next()!
|
|
group.cancelAll()
|
|
return result
|
|
}
|
|
}
|
|
} |