3de665d254
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
110 lines
4.2 KiB
Swift
110 lines
4.2 KiB
Swift
// DictionaryAliasGenerator.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// After the user manually adds or edits a personal-dictionary term,
|
|
// asks the configured polish LLM for common ASR misrecognitions.
|
|
// Shared by the iOS and macOS dictionary editors; persisted aliases are
|
|
// available to the keyboard extension on the next polish / correction call.
|
|
|
|
import Foundation
|
|
|
|
public struct DictionaryAliasGenerator: Sendable {
|
|
private let client: LLMClient?
|
|
private let timeout: TimeInterval
|
|
|
|
public init(client: LLMClient? = nil, timeout: TimeInterval = 12) {
|
|
self.client = client
|
|
self.timeout = timeout
|
|
}
|
|
|
|
public func generateAliases(for term: String) async -> [String] {
|
|
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return [] }
|
|
|
|
do {
|
|
let client = try resolveClient()
|
|
let prompt = Self.makePrompt(for: trimmed)
|
|
let raw = 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(timeout * 1_000_000_000))
|
|
throw CancellationError()
|
|
}
|
|
let result = try await group.next()!
|
|
group.cancelAll()
|
|
return result
|
|
}
|
|
return Self.parseAliases(from: raw, excludingTerm: trimmed)
|
|
} catch {
|
|
#if DEBUG
|
|
print("⚠️ [DictionaryAliasGenerator] alias generation failed: \(error)")
|
|
#endif
|
|
return []
|
|
}
|
|
}
|
|
|
|
private func resolveClient() throws -> LLMClient {
|
|
if let client {
|
|
return client
|
|
}
|
|
let store = AppGroupStore()
|
|
let providerId = store.providerId
|
|
let apiKey = store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !apiKey.isEmpty else {
|
|
throw LLMError.noAPIKey
|
|
}
|
|
let preset = LLMProvider.provider(id: providerId)
|
|
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
|
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
|
return LLMClientFactory.make(
|
|
providerId: providerId,
|
|
baseURL: baseURL,
|
|
apiKey: apiKey,
|
|
model: model,
|
|
thinkingEnabled: store.llmThinkingEnabled
|
|
)
|
|
}
|
|
|
|
private static func makePrompt(for term: String) -> String {
|
|
"""
|
|
你是语音识别纠错助手。用户把专有词汇「\(term)」加入了个人词库。
|
|
请列出该词在中文或英文语音输入时最常见的 3–6 个误识别写法(同音字、近音字、拼音混淆、英文误听等)。
|
|
不要包含正确词「\(term)」本身。
|
|
只输出 JSON 字符串数组,例如 ["误识别1","误识别2"]。若无合理别名则输出 []。
|
|
"""
|
|
}
|
|
|
|
public static func parseAliases(from raw: String, excludingTerm term: String) -> [String] {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let jsonSlice = extractJSONArray(from: trimmed) ?? trimmed
|
|
guard let data = jsonSlice.data(using: .utf8),
|
|
let decoded = try? JSONDecoder().decode([String].self, from: data)
|
|
else { return [] }
|
|
|
|
let termLower = term.lowercased()
|
|
var seen = Set<String>()
|
|
var aliases: [String] = []
|
|
for item in decoded {
|
|
let value = item.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty else { continue }
|
|
let key = value.lowercased()
|
|
guard key != termLower, !seen.contains(key) else { continue }
|
|
seen.insert(key)
|
|
aliases.append(value)
|
|
if aliases.count >= 6 { break }
|
|
}
|
|
return aliases
|
|
}
|
|
|
|
private static func extractJSONArray(from text: String) -> String? {
|
|
guard let start = text.firstIndex(of: "["),
|
|
let end = text.lastIndex(of: "]"),
|
|
start < end else {
|
|
return nil
|
|
}
|
|
return String(text[start...end])
|
|
}
|
|
}
|