3c10d73d7f
- 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.
673 lines
25 KiB
Swift
673 lines
25 KiB
Swift
// 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; 5–10 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."
|
||
}
|
||
}
|
||
}
|