feat(translation): add post-ASR translation mode for cloud engine
Adds an opt-in translation pipeline that reuses the existing PolishingService + LLMClient + AppGroupStore chain. Translation is implemented as a new PolishMode (.translate(targetLocaleId:)); all existing call sites are unchanged. Settings: - New TranslationPickerRow in the language tab (Toggle + 10-locale picker: en/zh-Hans/zh-Hant/ja/ko/fr/de/es/ru/pt), persisted to the App Group so the keyboard extension can read it during live dictation. - 5 new strings per language (en + zh-Hans). Keyboard: - New TranslationChip on the top bar to the right of LocaleChip; same Menu pattern, lets users toggle or quickly switch target language without leaving the keyboard. - PolishingService dispatches .translate with a parameterised prompt (en/zh variants selected by provider id); PolishingService.error gains a translationNotAvailable case so local-engine users get a clear inline warning when the toggle is on but cloud is off. - 6 new strings per language (en + zh-Hans) for the chip + banner. Local engine policy: - Translation is cloud-only by design (local engine stays ASR-only to honour the no-roundtrip promise). Chip shows a 'cloud required' state and raw transcript still inserts on failure — no data loss. Build: - OSGKeyboardShared adds TranslationLanguage enum (10 locales) and TranslationPrompt factory. - 4 new files, 9 modified. xcodebuild scheme=OSGKeyboard config=Debug destination=iPhone 17 Simulator: BUILD SUCCEEDED (0 warning, 0 error). Also pins DEVELOPMENT_TEAM in project.yml for TestFlight uploads (3 targets; Team X329MZU23S).
This commit is contained in:
@@ -29,6 +29,19 @@ public actor PolishingService {
|
||||
/// telling them to fill it in; we deliver the raw transcript
|
||||
/// so no data is lost.
|
||||
case missingAPIKey
|
||||
/// v0.2.1: the user requested translation but the active engine
|
||||
/// can't honour it (e.g. `engineMode == "local"`). The keyboard
|
||||
/// surfaces a short hint and falls back to the plain polish path.
|
||||
case translationNotAvailable
|
||||
}
|
||||
|
||||
/// v0.2.1: what the LLM should do with the raw transcript. The
|
||||
/// polish path stays the default so every existing call site keeps
|
||||
/// its current behaviour — translation is opt-in via the `translate`
|
||||
/// case and gets a target-locale parameter baked into the prompt.
|
||||
public enum PolishMode: Equatable, Sendable {
|
||||
case polish
|
||||
case translate(targetLocaleId: String)
|
||||
}
|
||||
|
||||
private let store: AppGroupStore
|
||||
@@ -52,10 +65,20 @@ public actor PolishingService {
|
||||
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
|
||||
}
|
||||
|
||||
public func polish(_ raw: String) async throws -> String {
|
||||
public func polish(_ raw: String, mode: PolishMode = .polish) async throws -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
// Translation requires the cloud engine (and therefore an API
|
||||
// key + base URL). When the user toggles translation on while
|
||||
// the local engine is active we refuse the mode so the keyboard
|
||||
// can fall back to a plain polish (or raw ASR) and surface a
|
||||
// short hint. This keeps the local engine's "ASR only" promise
|
||||
// intact.
|
||||
if case .translate = mode, store.engineMode != "cloud" {
|
||||
throw PolishError.translationNotAvailable
|
||||
}
|
||||
|
||||
// Local engine: ASR-only unless the user opted into cloud
|
||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
||||
// path still requires an API key; if the Keychain is empty we
|
||||
@@ -66,15 +89,15 @@ public actor PolishingService {
|
||||
guard !store.apiKey.isEmpty else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
return try await polishRemote(trimmed)
|
||||
return try await polishRemote(trimmed, mode: mode)
|
||||
}
|
||||
|
||||
return try await polishRemote(trimmed)
|
||||
return try await polishRemote(trimmed, mode: mode)
|
||||
}
|
||||
|
||||
private func polishRemote(_ trimmed: String) async throws -> String {
|
||||
private func polishRemote(_ trimmed: String, mode: PolishMode) async throws -> String {
|
||||
let client = injectedClient ?? store.makeClient()
|
||||
let prompt = store.systemPrompt
|
||||
let prompt = resolvedSystemPrompt(for: mode)
|
||||
let budget = effectiveTimeout(for: trimmed)
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
@@ -91,6 +114,21 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.2.1: pick the right system prompt for the requested mode.
|
||||
/// Translation mode swaps in the parameterized translate-and-polish
|
||||
/// prompt (see `TranslationPrompt.make`); polish mode keeps the
|
||||
/// existing `store.systemPrompt` behaviour so every other call site
|
||||
/// is byte-identical to before.
|
||||
private func resolvedSystemPrompt(for mode: PolishMode) -> String {
|
||||
switch mode {
|
||||
case .polish:
|
||||
return store.systemPrompt
|
||||
case .translate(let targetLocaleId):
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
return TranslationPrompt.make(target: target, providerId: store.providerId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user