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:
@@ -39,6 +39,12 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// in the local engine. Default `false` — keeps the local engine
|
||||
// truly local unless the user explicitly opts in.
|
||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
||||
// v0.2.1: optional translation step after ASR. When enabled, the
|
||||
// post-ASR transcript is routed through the same LLM with a
|
||||
// translate-and-polish prompt targeting `translationTargetLocaleId`.
|
||||
// Mutually exclusive with the local-only promise — see `TranslationPolicy`.
|
||||
static let translationEnabled = "config.translationEnabled"
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
@@ -115,6 +121,29 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
@Published public var uiLanguage: AppUILanguage {
|
||||
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
|
||||
}
|
||||
/// v0.2.1: whether to translate the transcript into
|
||||
/// `translationTargetLocaleId` before insertion. Persisted in the App
|
||||
/// Group so the keyboard extension can honour it (and so the chip on
|
||||
/// the keyboard reflects the user's choice without a host-app round-
|
||||
/// trip). Default `false` — translation is opt-in.
|
||||
@Published public var translationEnabled: Bool {
|
||||
didSet { defaults.set(translationEnabled, forKey: Key.translationEnabled) }
|
||||
}
|
||||
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
||||
/// translate-and-polish prompt should produce. Default `"en"`.
|
||||
/// Persisted in the App Group for the same reason as `translationEnabled`.
|
||||
@Published public var translationTargetLocaleId: String {
|
||||
didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) }
|
||||
}
|
||||
|
||||
/// v0.2.1: hard gate that decides whether the translation feature can
|
||||
/// actually run. The keyboard honors `translationEnabled` only when
|
||||
/// `engineMode == "cloud"` — the local engine is contractually ASR-
|
||||
/// only, so translation is silently ignored (and the UI shows a hint)
|
||||
/// even when the toggle is on.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled && engineMode == "cloud"
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
// Local engine (on-device ASR only) doesn't need an API key,
|
||||
@@ -193,6 +222,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
self.uiLanguage = AppUILanguage.fromStored(
|
||||
resolvedDefaults.string(forKey: Key.uiLanguage)
|
||||
)
|
||||
// v0.2.1: translation toggle + target locale. Both default in a
|
||||
// backwards-compatible way so existing installs keep their old
|
||||
// behaviour (`false`/English) without prompting.
|
||||
if resolvedDefaults.object(forKey: Key.translationEnabled) == nil {
|
||||
self.translationEnabled = false
|
||||
} else {
|
||||
self.translationEnabled = resolvedDefaults.bool(forKey: Key.translationEnabled)
|
||||
}
|
||||
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) ?? "en"
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// TranslationLanguage.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Catalog of target languages the translation feature can produce.
|
||||
//
|
||||
// Kept deliberately small (~10 entries) to match the kind of choices
|
||||
// the user makes in the Settings picker / keyboard chip. We don't try
|
||||
// to expose every BCP-47 locale — the prompt just needs a target
|
||||
// language name, and a curated list reads better than a 100-row scroll.
|
||||
//
|
||||
// `id` is what gets persisted to the App Group. `promptLanguageName`
|
||||
// is the human-readable target name injected into the prompt (e.g.
|
||||
// the LLM sees "English", not "en"). `nativeName` is the endonym we
|
||||
// show in the picker UI ("日本語" instead of "Japanese").
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TranslationLanguage: Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let promptLanguageName: String
|
||||
public let nativeName: String
|
||||
|
||||
public init(id: String, promptLanguageName: String, nativeName: String) {
|
||||
self.id = id
|
||||
self.promptLanguageName = promptLanguageName
|
||||
self.nativeName = nativeName
|
||||
}
|
||||
}
|
||||
|
||||
public enum TranslationLanguageCatalog {
|
||||
/// Default target language id used on fresh installs.
|
||||
public static let defaultLocaleId = "en"
|
||||
|
||||
/// Curated set. Order matters — the picker / chip render top-to-
|
||||
/// bottom, and `defaultLocaleId` is the default selection.
|
||||
public static let all: [TranslationLanguage] = [
|
||||
TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"),
|
||||
TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"),
|
||||
TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"),
|
||||
TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"),
|
||||
TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"),
|
||||
TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"),
|
||||
TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"),
|
||||
TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"),
|
||||
TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"),
|
||||
TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"),
|
||||
]
|
||||
|
||||
/// Resolve a stored locale id to its catalog entry. Falls back to
|
||||
/// `defaultLocaleId` when the id is missing or unknown — matches the
|
||||
/// pattern used elsewhere (e.g. `ASRLocaleLabels`) so the keyboard
|
||||
/// never crashes on a stale persisted value.
|
||||
public static func resolve(_ id: String) -> TranslationLanguage {
|
||||
if let match = all.first(where: { $0.id == id }) {
|
||||
return match
|
||||
}
|
||||
return all.first { $0.id == defaultLocaleId } ?? all[0]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user