Files
OSGKeyboard/OSGKeyboardShared/Services/PolishingService.swift
T
Rocky 3c10d73d7f feat(ai): unify reply center and refresh keyboard AI features
- Merge invitation, task, blessing, clarification, and empathy actions
  into a single Reply flow, with three fixed, clearly labeled stance
  choices whenever user intent must not be guessed.
- Refine clipboard semantic routing with bilingual schedule,
  confirmation, and follow-up models, conservative language thresholds,
  and explicit-assignment guard for complaint-only text.
- Persist Apple account refresh state, harden session recovery, and
  surface durable account diagnostics across keyboard and app.
- Derive personal-style prompts through two-stage corpus evidence and
  apply real low-confidence ASR tendencies instead of neutral templates.
- Localize the new reply center, clipboard semantics, and personal-style
  surfaces in both English and Simplified Chinese.
2026-08-29 11:51:21 +08:00

673 lines
25 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
//
// One-step intelligent polish combines ASR error correction, filler
// removal, and tone adaptation in a single LLM call. Keeping these
// operations merged avoids the latency and token cost of separate
// correction and polish requests; 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 polish
// - 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
// - Missing polish API key → raw ASR + `.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
/// Exact personality snapshot used by a normal polish request.
/// Translation and caller-supplied system prompts leave these nil.
public let polishStyleID: String?
public let polishStylePrompt: String?
public init(
text: String,
qualityDegraded: Bool = false,
polishStyleID: String? = nil,
polishStylePrompt: String? = nil
) {
self.text = text
self.qualityDegraded = qualityDegraded
self.polishStyleID = polishStyleID
self.polishStylePrompt = polishStylePrompt
}
}
private struct RemotePolishResult: Sendable {
let text: String
let qualityDegraded: Bool
}
private struct PolishRequest {
let raw: String
let mode: PolishMode
let systemPrompt: String?
let options: LLMGenerationOptions?
let providerIdOverride: String?
let taskKind: ManagedGatewayTaskKind?
let requestPurpose: ManagedGatewayRequestPurpose?
let oobeFeature: ManagedGatewayOOBEFeature?
let context: PolishContext?
}
public enum PolishError: Error, Equatable {
case noTranscript
case timeout
/// Polish LLM Keychain entry is empty for the resolved provider.
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
}
/// 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
private let maximumTimeout: TimeInterval
private let analyticsClient: any AnalyticsClient
/// 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.
/// `maximumTimeout` defaults to the keyboard watchdog-compatible 35 s;
/// explicit host workflows may raise it together with their baseline.
public init(
store: any ConfigurationStore = AppGroupStore(),
client: LLMClient? = nil,
timeout: TimeInterval? = nil,
maximumTimeout: TimeInterval = FlowSessionKeys.maxPolishTimeout,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
self.maximumTimeout = maximumTimeout
self.analyticsClient = analyticsClient
}
/// 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 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,
options: LLMGenerationOptions? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
context: PolishContext? = nil
) async throws -> String {
try await performPolish(
PolishRequest(
raw: raw,
mode: mode,
systemPrompt: systemPrompt,
options: options,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
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,
options: LLMGenerationOptions? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
context: PolishContext? = nil
) async throws -> PolishOutcome {
try await performPolish(
PolishRequest(
raw: raw,
mode: mode,
systemPrompt: systemPrompt,
options: options,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
context: context
)
)
}
private func performPolish(_ request: PolishRequest) async throws -> PolishOutcome {
let raw = request.raw
let mode = request.mode
let systemPrompt = request.systemPrompt
let providerIdOverride = request.providerIdOverride
let taskKind = request.taskKind
let requestPurpose = request.requestPurpose
let oobeFeature = request.oobeFeature
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
let resolvedContext = resolveContext(override: request.context)
// Resolve once so prompt construction, output validation, and history
// metadata all describe the same immutable style even if settings change
// while the request is in flight.
let activeStyle = PolishStylePackCatalog.resolve(
id: store.activePolishStyleId,
userCatalog: store.polishStyleCatalog
)
// Two-tier short-circuit: ultra-short always; 510 CJK only for
// low-value acks/closings (see TranscriptPostProcessor).
if mode == .polish,
requestPurpose != .oobe,
systemPrompt == nil || systemPrompt?.isEmpty == true,
TranscriptPostProcessor.shouldSkipLLM(
for: trimmed,
styleID: activeStyle.id
) {
FlowTrace.polish(
"skippedLLM",
"style=\(activeStyle.id) intensity=\(store.polishIntensity.rawValue) "
+ "inputLen=\(trimmed.count)"
)
return PolishOutcome(text: TranscriptPostProcessor.localClean(trimmed))
}
if injectedClient == nil,
store.credentialSource == .byok,
requestPurpose != .oobe {
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 operation = analyticsClient.startAIFeature(
.polish,
executionMode: analyticsExecutionMode
)
let remoteResult: RemotePolishResult
do {
remoteResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
options: request.options,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
context: resolvedContext,
activeStyle: activeStyle
)
operation.succeed()
} catch {
operation.fail(category: Self.analyticsFailureCategory(for: error))
throw error
}
// 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,
polishStyleID: remoteResult.qualityDegraded ? nil : activeStyle.id,
polishStylePrompt: remoteResult.qualityDegraded
? nil
: PolishStylePackCatalog.runtimePersonality(for: activeStyle)
)
}
private func resolveContext(override: PolishContext?) -> PolishContext {
guard let override else {
return PolishContext(
appContext: store.detectedAppContext?.context ?? .unknown
)
}
return override
}
private var analyticsExecutionMode: AnalyticsExecutionMode {
store.credentialSource == .managed ? .managed : .byok
}
private static func analyticsFailureCategory(
for error: Error
) -> AnalyticsFailureCategory {
if error is CancellationError {
return .cancelled
}
if let error = error as? PolishError {
switch error {
case .timeout:
return .timeout
case .noTranscript, .missingAPIKey, .keychainLocked:
return .validation
}
}
if let error = error as? ManagedGatewayError {
switch error {
case .insufficientCredits:
return .insufficientCredits
case .timeout, .providerTimeout:
return .timeout
case .missingGrant, .scopeNotGranted, .invalidGrant, .oobeFeatureAlreadyUsed:
return .validation
case .providerRateLimited:
return .network
case .providerUnavailable, .providerFailure, .internalFailure, .server:
return .provider
}
}
if let error = error as? LLMError {
switch error {
case .cancelled:
return .cancelled
case .timeout:
return .timeout
case .transport, .rateLimited:
return .network
case .invalidURL, .noAPIKey, .decoding:
return .validation
case .http:
return .provider
}
}
return .unknown
}
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
switch mode {
case .polish:
return .dictationPolish
case .translate:
return .translation
}
}
private func polishRemote(
_ trimmed: String,
mode: PolishMode,
systemPrompt: String? = nil,
options: LLMGenerationOptions? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
context: PolishContext,
activeStyle: PolishStylePack
) async throws -> RemotePolishResult {
let effectiveProviderId = Self.resolvedProviderId(
store: store,
providerIdOverride: providerIdOverride
)
let client: LLMClient
if let injectedClient {
client = injectedClient
} else if store.credentialSource == .managed || requestPurpose == .oobe {
client = store.makeClient(
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode),
requestPurpose: requestPurpose,
oobeFeature: oobeFeature
)
} else {
let preset = LLMProvider.provider(id: effectiveProviderId)
let (baseURL, model) = Self.resolveLLMEndpoint(
store: store,
preset: preset,
providerIdOverride: providerIdOverride
)
let apiKey = Self.userAPIKey(
store: store,
providerId: effectiveProviderId
)
guard !apiKey.isEmpty else { throw PolishError.missingAPIKey }
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,
style: activeStyle
)
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: activeStyle.id,
intensity: store.polishIntensity
)
let firstOptions = options
?? (usesHeavyFunPersonality ? .funCreative : .polishDefault)
logPolishConfiguration(
prompt: prompt,
mode: mode,
systemPromptOverride: systemPrompt,
usesHeavyFunPersonality: usesHeavyFunPersonality,
options: firstOptions,
context: context,
inputLength: trimmed.count,
styleID: activeStyle.id
)
let userPayload: String
if mode == .polish, systemPrompt == nil || systemPrompt?.isEmpty == true {
userPayload = PolishPromptComposer.dictationUserPayload(trimmed)
} else {
userPayload = trimmed
}
let first = try await performLLMRequest(
client: client,
text: userPayload,
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 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 HardTimeoutError.timedOut {
throw PolishError.timeout
}
}
/// Records which style, intensity, sampling profile and safeguard layers
/// this request actually used. Without it, an unexpected reply can only be
/// attributed to a style/intensity combination by guesswork.
private func logPolishConfiguration(
prompt: String,
mode: PolishMode,
systemPromptOverride: String?,
usesHeavyFunPersonality: Bool,
options: LLMGenerationOptions,
context: PolishContext,
inputLength: Int,
styleID: String
) {
let hasOverride = !(systemPromptOverride ?? "").isEmpty
let fingerprint = PolishPromptComposer.fingerprint(of: prompt)
let temperature = options.temperature.map { String(format: "%.2f", $0) } ?? "nil"
FlowTrace.polish(
"config",
"style=\(styleID) intensity=\(store.polishIntensity.rawValue) "
+ "mode=\(Self.polishModeLabel(mode)) heavyFun=\(usesHeavyFunPersonality ? 1 : 0) "
+ "override=\(hasOverride ? 1 : 0) temp=\(temperature) "
+ "inputLen=\(inputLength) beforeLen=\(context.precedingForPrompt?.count ?? 0) "
+ fingerprint.logLabel
)
}
private static func polishModeLabel(_ mode: PolishMode) -> String {
switch mode {
case .polish: return "polish"
case .translate: return "translate"
}
}
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 style = PolishStylePackCatalog.resolve(
id: store.activePolishStyleId,
userCatalog: store.polishStyleCatalog
)
return buildPrompt(
for: text,
context: context,
providerId: providerId,
style: style
)
}
private func buildPrompt(
for text: String,
context: PolishContext,
providerId: String,
style: PolishStylePack
) -> String {
let dictionaryBlock = Self.mergedDictionaryBlock(
dictionary: store.personalDictionary,
supplement: context.dictionarySupplement
)
let useChinese = Self.shouldUseChineseGuidance(inputText: text, providerId: providerId)
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 by `maximumTimeout`.
///
/// 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 min(
FlowSessionKeys.polishTimeout(forCharacterCount: text.count),
maximumTimeout
)
}
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), maximumTimeout)
}
internal static func resolvedProviderId(
store: any ConfigurationStore,
providerIdOverride: String?
) -> String {
if let providerIdOverride {
return providerIdOverride
}
return store.providerId
}
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
!userAPIKey(store: store, providerId: providerId).isEmpty
}
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)
}
/// Resolve baseURL + model for polish and AI mode. Empty store fields fall
/// back to the provider preset defaults so Settings remains the single
/// source of truth for both dictation polish and AI keyboard questions.
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 — fill it in Settings before polish can run."
case .keychainLocked:
return "API key unavailable while the device is locked — will work after unlock."
}
}
}