Files
OSGKeyboard/OSGKeyboardShared/Services/PolishingService.swift
T
Rocky a8d58d8f0c feat(polish): allow mood emoji on custom styles and ship Flow/ASR fixes
Custom polish styles can opt in to emotion-matched emoji (default off), with
prompt-level opt-in detection so paste-only styles keep model-added emoji.
Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash
fix, and related keyboard Shift/haptics reliability work.
2026-08-06 15:11:12 +08:00

480 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PolishingService.swift
// OSGKeyboard · Shared
//
// v0.3.0 rewrite: one-step "intelligent" polish that combines ASR
// error correction, filler removal, and tone adaptation in a single
// LLM call. The previous design was two separate steps (correction
// then polish) which doubled latency and token cost; Typeless,
// Wispr Flow, and the "intelligent" rewrite literature all confirm
// the merged prompt performs just as well for everyday Chinese /
// English dictation while halving the network round-trip.
//
// Engine matrix:
// - `engineMode == "cloud"` → user's cloud ASR + user's cloud LLM (independent)
// - `engineMode == "local"` → on-device ASR + user's LLM (or built-in DeepSeek)
// - Ultra-short / low-value short utterances skip the LLM entirely
// (two-tier gate in TranscriptPostProcessor)
// - Fun styles use full safeguards at light intensity and the
// formatting-only creative path at heavy intensity
// - Daily Chat keeps a local sparse-input safety brake
// - Cloud without API key → raw + `.missingAPIKey` warning
// - Local without build key → raw + `.missingAPIKey` warning
//
// Caller-supplied `PolishContext` carries the per-call signals:
// - `appContext` code / email / chat / document / unknown
// - `precedingText` optional tail of the cursor's preceding text
// for reference resolution
//
// The prompt is intentionally a single message; multi-message
// conversation history would let earlier hallucinations pollute
// later calls (see MIT 2026 "Do LLMs Benefit From Their Own Words?")
// and the user expectation is that each take is independent.
import Foundation
public actor PolishingService {
public struct PolishOutcome: Sendable, Equatable {
public let text: String
public let qualityDegraded: Bool
public init(text: String, qualityDegraded: Bool = false) {
self.text = text
self.qualityDegraded = qualityDegraded
}
}
private struct RemotePolishResult: Sendable {
let text: String
let qualityDegraded: Bool
}
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
/// The keychain was unreadable (device locked before first unlock)
/// — the key likely EXISTS; treat as transient, never as "please
/// re-enter your API key".
case keychainLocked
}
/// 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: any ConfigurationStore
private let timeout: TimeInterval
/// Optional injected client (mostly for testing). When nil we build
/// one from `store.makeClient()` per call.
private let injectedClient: LLMClient?
/// `timeout` is the baseline (shortest) per-request HTTP timeout,
/// used as the floor for `effectiveTimeout(for:)`. It defaults to the
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its
/// own slack on top of the length-scaled budget in `polishRemote`, so
/// no `+1` is baked in here.
public init(
store: any ConfigurationStore = AppGroupStore(),
client: LLMClient? = nil,
timeout: TimeInterval? = nil
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
}
/// v0.3.0: context-aware polish entry point. The optional
/// `PolishContext` carries per-call signals (app context,
/// intensity, preceding text). Translation is a separate concept
/// (see `mode` below) so callers wanting the v0.2.1 translate
/// flow should keep using the override prompt / providerId
/// overloads exposed by the host.
public func polish(
_ raw: String,
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
context: PolishContext? = nil
) async throws -> String {
try await performPolish(
raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
).text
}
/// Additive result API for host pipelines that need to surface a conservative
/// quality fallback without changing the established `polish` signature.
public func polishWithOutcome(
_ raw: String,
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
context: PolishContext? = nil
) async throws -> PolishOutcome {
try await performPolish(
raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
)
}
private func performPolish(
_ raw: String,
mode: PolishMode,
systemPrompt: String?,
providerIdOverride: String?,
context: PolishContext?
) async throws -> PolishOutcome {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
let resolvedContext = resolveContext(override: context)
let activeStyleID = store.activePolishStyleId
// Two-tier short-circuit: ultra-short always; 510 CJK only for
// low-value acks/closings (see TranscriptPostProcessor).
if mode == .polish,
systemPrompt == nil || systemPrompt?.isEmpty == true,
TranscriptPostProcessor.shouldSkipLLM(
for: trimmed,
styleID: activeStyleID
) {
return PolishOutcome(text: TranscriptPostProcessor.localClean(trimmed))
}
if injectedClient == nil {
let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride)
let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId)
guard hasPolishKey else {
if case .unavailable = Keychain.apiKeyOutcome(for: providerId, preferICloudSync: true) {
throw PolishError.keychainLocked
}
throw PolishError.missingAPIKey
}
}
let remoteResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: resolvedContext
)
// Translation and custom prompts bypass the polish post-processor.
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
return PolishOutcome(text: remoteResult.text)
}
return PolishOutcome(
text: remoteResult.text,
qualityDegraded: remoteResult.qualityDegraded
)
}
private func resolveContext(override: PolishContext?) -> PolishContext {
guard let override else {
return PolishContext(
appContext: store.detectedAppContext?.context ?? .unknown
)
}
return override
}
private func polishRemote(
_ trimmed: String,
mode: PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
context: PolishContext
) async throws -> RemotePolishResult {
let effectiveProviderId = Self.resolvedProviderId(
store: store,
providerIdOverride: providerIdOverride
)
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
let userKey = Self.userAPIKey(
store: store,
providerId: effectiveProviderId
)
if effectiveProviderId == "deepseek" {
if !userKey.isEmpty {
apiKey = userKey
} else if PreconfiguredKeys.isDeepseekConfigured {
apiKey = PreconfiguredKeys.deepseek
} else {
throw PolishError.missingAPIKey
}
} else {
apiKey = userKey
}
client = LLMClientFactory.make(
providerId: effectiveProviderId,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: store.llmThinkingEnabled
)
}
let prompt: String
if let override = systemPrompt, !override.isEmpty {
prompt = override
} else {
switch mode {
case .polish:
prompt = buildPrompt(
for: trimmed,
context: context,
providerId: effectiveProviderId
)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
prompt = TranslationPrompt.make(
target: target,
providerId: effectiveProviderId,
appContext: context.appContext,
sourceText: trimmed
)
}
}
let budget = effectiveTimeout(for: trimmed)
let usesHeavyFunPersonality = mode == .polish
&& (systemPrompt == nil || systemPrompt?.isEmpty == true)
&& PolishStylePackCatalog.usesFormattingOnlyPipeline(
id: store.activePolishStyleId,
intensity: store.polishIntensity
)
let firstOptions: LLMGenerationOptions = usesHeavyFunPersonality
? .funCreative
: .polishDefault
let first = try await performLLMRequest(
client: client,
text: trimmed,
prompt: prompt,
timeout: budget,
options: firstOptions
)
guard mode == .polish, systemPrompt == nil || systemPrompt?.isEmpty == true else {
return RemotePolishResult(text: first, qualityDegraded: false)
}
// One prompt, one model request. Deterministic validation may reject a
// result locally, but it never starts a second polish request.
let activeStyle = PolishStylePackCatalog.resolve(
id: store.activePolishStyleId,
userCatalog: store.polishStyleCatalog
)
let firstCandidate = TranscriptPostProcessor.process(
original: trimmed,
llmOutput: first,
allowsAddedEmoji: activeStyle.effectiveAllowsAddedEmoji
)
let firstViolations = PolishOutputValidator.validate(
input: trimmed,
output: firstCandidate,
dictionary: store.personalDictionary
)
logViolations(firstViolations, attempt: 1)
guard !firstViolations.isEmpty else {
return RemotePolishResult(text: firstCandidate, qualityDegraded: false)
}
return RemotePolishResult(
text: validationFallback(text: trimmed),
qualityDegraded: true
)
}
private func validationFallback(text: String) -> String {
return TranscriptPostProcessor.minimalPolish(text)
}
private func performLLMRequest(
client: any LLMClient,
text: String,
prompt: String,
timeout: TimeInterval,
options: LLMGenerationOptions
) async throws -> String {
let safetyNet = timeout + 2
do {
return try await HardTimeout.run(seconds: safetyNet) {
try await client.polish(
text,
systemPrompt: prompt,
timeout: timeout,
options: options
)
}
} catch is CancellationError {
throw PolishError.timeout
}
}
private func logViolations(_ violations: [PolishViolation], attempt: Int) {
guard !violations.isEmpty else { return }
FlowTrace.polish(
"validation",
"attempt=\(attempt) " + violations.map(\.logLabel).joined(separator: ",")
)
}
internal func buildPrompt(
for text: String,
context: PolishContext,
providerId: String
) -> String {
let dictionaryBlock = Self.mergedDictionaryBlock(
dictionary: store.personalDictionary,
supplement: context.dictionarySupplement
)
let useChinese = Self.shouldUseChineseGuidance(inputText: text, providerId: providerId)
let style = PolishStylePackCatalog.resolve(
id: store.activePolishStyleId,
userCatalog: store.polishStyleCatalog
)
return PolishPromptComposer.compose(
text: text,
style: style,
context: context,
dictionaryBlock: dictionaryBlock,
intensity: store.polishIntensity,
useChineseGuidance: useChinese
)
}
internal static func mergedDictionaryBlock(
dictionary: PersonalDictionary,
supplement: String?
) -> String {
let base = dictionary.promptFragment()
let extra = supplement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if base.isEmpty { return extra }
if extra.isEmpty { return base }
return base + "\n" + extra
}
internal static let chineseNativeProviderIds: Set<String> = [
"zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo",
]
internal static func shouldUseChineseGuidance(inputText: String, providerId: String) -> Bool {
let ratio = TranscriptLanguageDetector.cjkRatio(inputText)
if ratio >= 0.15 { return true }
if ratio > 0 { return false }
return chineseNativeProviderIds.contains(providerId)
}
/// Per-request HTTP timeout, scaled with transcript length. This is
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
/// dictations (which generate long, listified, multi-paragraph output)
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
/// ~10 s per 100 characters, capped at 120 s.
///
/// Previously this value was computed but only used for the safety-net
/// timer while the URLRequest stayed pinned at 15 s — the scaling was
/// dead code and long transcripts timed out, falling back to the raw
/// (unpolished, unsegmented) ASR text.
internal func effectiveTimeout(for text: String) -> TimeInterval {
if timeout == LLMClientFactory.defaultRequestTimeout {
return FlowSessionKeys.polishTimeout(forCharacterCount: text.count)
}
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
// The cap participates in the keyboard-watchdog budget — see
// `FlowSessionKeys.keyboardResultTimeout`. Raising it here without
// going through that constant would silently break the invariant
// "keyboard timeout > host worst case".
return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout)
}
internal static func resolvedProviderId(
store: any ConfigurationStore,
providerIdOverride: String?
) -> String {
if let providerIdOverride {
return providerIdOverride
}
let id = store.providerId
// Local installs without a user LLM key keep using the built-in DeepSeek path.
if store.engineMode == "local",
id != "deepseek",
store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
PreconfiguredKeys.isDeepseekConfigured {
return "deepseek"
}
return id
}
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
if !userAPIKey(store: store, providerId: providerId).isEmpty {
return true
}
if providerId == "deepseek", PreconfiguredKeys.isDeepseekConfigured {
return true
}
return false
}
private static func userAPIKey(
store: any ConfigurationStore,
providerId: String
) -> String {
let key = providerId == store.providerId
? store.apiKey
: Keychain.apiKey(for: providerId, preferICloudSync: true) ?? ""
return key.trimmingCharacters(in: .whitespacesAndNewlines)
}
internal static func resolveLLMEndpoint(
store: any ConfigurationStore,
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
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 (cloud: Settings API key; local: build configuration)."
case .keychainLocked:
return "API key unavailable while the device is locked — will work after unlock."
}
}
}