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:
2026-06-25 12:45:44 +08:00
parent dc9697bf3d
commit deddb49d56
17 changed files with 620 additions and 6 deletions
@@ -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