Files
OSGKeyboard/OSGKeyboardShared/Services/PolishingService.swift
T
Rocky 1bdb8824ac feat: polish scenarios and stabilize keyboard layout height
Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
2026-06-29 00:08:00 +08:00

223 lines
8.8 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.
//
// Engine matrix:
// - `engineMode == "cloud"` → always polish (cloud engine's whole point).
// - `engineMode == "local"`,
// cloud polish disabled → ASR-only, return raw.
// - `engineMode == "local"`,
// cloud polish enabled → DeepSeek LLM step (polish or translate).
// Translation uses `.translate` + `TranslationPrompt`; polish uses
// the default system prompt. Missing preconfigured DeepSeek key
// throws `missingAPIKey` and callers deliver raw + warning.
import Foundation
public actor PolishingService {
public enum PolishError: Error, Equatable {
case noTranscript
case timeout
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
/// still the repo placeholder, or cloud engine Keychain is empty.
case missingAPIKey
}
/// 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
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)
}
/// 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 }
// Local engine: ASR-only unless cloud polish is enabled
// (translation is a sub-option of that LLM step).
if store.engineMode == "local" {
guard store.shouldRunCloudLLMStep else { return trimmed }
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
}
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
}
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/apiKey quartet 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, model) = Self.resolveLLMEndpoint(
store: store,
preset: preset,
providerIdOverride: providerIdOverride
)
let apiKey: String
if effectiveProviderId == "deepseek" {
let preconfigured = PreconfiguredKeys.deepseek
if preconfigured == "TODO_FILL_LATER_DEEPSEEK_KEY" {
// Placeholder still in place — refuse the round-
// trip so the UI can surface a "build not
// configured" hint instead of a 401.
throw PolishError.missingAPIKey
}
apiKey = preconfigured
} else {
apiKey = store.apiKey
}
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
}
let prompt = resolvedSystemPrompt(
for: mode,
override: systemPrompt,
providerId: effectiveProviderId
)
let budget = effectiveTimeout(for: trimmed)
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(budget * 1_000_000_000))
throw PolishError.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
/// 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. 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,
providerId: String? = nil
) -> String {
if let override, !override.isEmpty {
return override
}
switch mode {
case .polish:
return store.resolvedPolishSystemPrompt(providerId: providerId)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let pid = providerId ?? store.providerId
return TranslationPrompt.make(
target: target,
providerId: pid,
scenarioId: store.polishScenarioId,
uiLanguage: store.uiLanguage
)
}
}
/// 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
return min(max(scaled, timeout), 120)
}
/// Picks base URL + model for one remote polish call.
///
/// When `providerIdOverride` is set (local engine pins DeepSeek),
/// always use that preset's defaults so cloud-engine settings
/// (e.g. Qwen base URL saved while testing cloud mode) are not
/// mixed with the pinned provider's API key. Cloud engine passes
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
internal static func resolveLLMEndpoint(
store: AppGroupStore,
preset: LLMProvider,
providerIdOverride: String?
) -> (baseURL: String, model: String) {
if providerIdOverride != nil {
return (preset.defaultBaseURL, preset.defaultModel)
}
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
// Pre-existing typo fix: the user-overridden `store.model`
// path was returning `preset.defaultModel` on both branches,
// silently ignoring the user's custom model field. Restore
// the asymmetry so the user override actually wins.
let model = store.model.isEmpty ? preset.defaultModel : store.model
return (baseURL, model)
}
}
extension PolishingService.PolishError: LocalizedError {
public var errorDescription: String? {
switch self {
case .noTranscript:
return "No transcript to polish."
case .timeout:
return "LLM polish timed out."
case .missingAPIKey:
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
}
}
}