1bdb8824ac
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.
223 lines
8.8 KiB
Swift
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)."
|
|
}
|
|
}
|
|
}
|