feat(translation-v2): final review — dual-engine translation row + DeepSeek polish override + dead code cleanup

This commit is contained in:
2026-06-25 14:25:06 +08:00
parent 9b759281cc
commit 0956bb8534
12 changed files with 163 additions and 102 deletions
+22 -14
View File
@@ -149,24 +149,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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.
/// v0.2.1 follow-up: with the local engine's translate-and-polish
/// path now real (see `localModeProviderId`), `translationEnabled`
/// alone is enough to decide whether the pipeline should translate.
/// Row visibility (`isTranslationRowVisible`) already gates the UI
/// on engines that can actually run the step, so we don't need to
/// re-check `engineMode` here.
public var isTranslationEffective: Bool {
translationEnabled && engineMode == "cloud"
translationEnabled
}
/// v0.2.1: row visibility predicate. Translation is shown only when
/// the engine can actually run the cloud translate-and-polish step:
/// - cloud engine: always visible
/// - local engine: visible only when cloud polish is also enabled
/// (otherwise translation is silently inert the local engine
/// rejects `.translate` upstream, so we'd be advertising a
/// feature that can't run).
/// v0.2.1 follow-up: row visibility predicate. Both engines can
/// now run the cloud translate-and-polish step (the local engine
/// routes through DeepSeek via `localModeProviderId`), so the row
/// is shown whenever an engine mode is selected.
public var isTranslationRowVisible: Bool {
(engineMode == "cloud") || (engineMode == "local" && localModeCloudPolishEnabled)
engineMode == "local" || engineMode == "cloud"
}
public var isConfigured: Bool {
@@ -197,6 +195,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
isLocalEngine && localModeCloudPolishEnabled
}
/// v0.2.1 follow-up: when the local engine is using the cloud-
/// polish step, route the call through DeepSeek cheap, strong
/// on Chinese, and the right default for the on-device ASR
/// transcript. Other engines honor the user's configured
/// `providerId` unchanged so cloud users keep their preferred
/// vendor (OpenAI / Anthropic / Zhipu / etc).
public var localModeProviderId: String {
isLocalEngine ? "deepseek" : providerId
}
/// The system prompt the user *sees* in the editor fall back to the
/// provider-aware default from `AppGroupStore` when nothing is set.
public var defaultSystemPrompt: String {
@@ -98,12 +98,14 @@ public final class KeyboardState: ObservableObject {
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// v0.2.1: effective predicate translation is honoured only on
/// the cloud engine. The keyboard's chip / picker read this so the
/// UI can show a "" hint when the toggle is on while the
/// local engine is active.
/// v0.2.1: effective predicate mirrors `ProviderConfig`.
/// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"`
/// because the local engine's translate-and-polish step now runs
/// (routed through DeepSeek). Row visibility (`isTranslationRowVisible`
/// on `ProviderConfig`) keeps the picker honest, so the keyboard
/// can rely on `translationEnabled` alone here.
public var isTranslationEffective: Bool {
translationEnabled && engineMode == "cloud"
translationEnabled
}
/// Convenience shorthand used by the pipeline and views.
@@ -29,10 +29,6 @@ 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
@@ -65,20 +61,19 @@ public actor PolishingService {
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
}
public func polish(_ raw: String, mode: PolishMode = .polish) async throws -> String {
/// v0.2.1 follow-up: `providerIdOverride` lets callers pin the
/// remote polish step to a specific provider (the local engine
/// pins to DeepSeek regardless of the user's chosen cloud
/// provider). Pass `nil` to honor `store.providerId` as before.
public func polish(
_ raw: String,
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil
) 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
@@ -89,15 +84,44 @@ public actor PolishingService {
guard !store.apiKey.isEmpty else {
throw PolishError.missingAPIKey
}
return try await polishRemote(trimmed, mode: mode)
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
}
return try await polishRemote(trimmed, mode: mode)
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
}
private func polishRemote(_ trimmed: String, mode: PolishMode) async throws -> String {
let client = injectedClient ?? store.makeClient()
let prompt = resolvedSystemPrompt(for: mode)
private func polishRemote(
_ trimmed: String,
mode: PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String? = nil
) async throws -> String {
// v0.2.1 follow-up: when the caller pins a provider id (the
// local engine pins DeepSeek) we still want to honor the
// injected test client, but we have to re-derive the
// preset/baseURL/model triplet from the *override* so the
// injected client gets the right values when it's nil.
let effectiveProviderId = providerIdOverride ?? store.providerId
let client: LLMClient
if let injectedClient {
client = injectedClient
} else {
let preset = LLMProvider.provider(id: effectiveProviderId)
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
let model = store.model.isEmpty ? preset.defaultModel : preset.defaultModel
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: store.apiKey, model: model)
}
let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt)
let budget = effectiveTimeout(for: trimmed)
return try await withThrowingTaskGroup(of: String.self) { group in
@@ -118,14 +142,19 @@ public actor PolishingService {
/// 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 {
/// is byte-identical to before. An explicit `override` wins over
/// both paths so callers (and tests) can pin a specific prompt.
private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String {
if let override, !override.isEmpty {
return override
}
switch mode {
case .polish:
return store.systemPrompt
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
return TranslationPrompt.make(target: target, providerId: store.providerId)
let pid = store.providerId
return TranslationPrompt.make(target: target, providerId: pid)
}
}