feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion
Mirror the personal dictionary through iCloud Key-Value Store with deterministic merge rules, main-app-only sync UI, and App Group as the keyboard runtime cache. Add cloud-engine ASR with dictionary bias and expand the bundled custom language model lexicon.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// PersonalDictionary+ASRBias.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
|
||||
// vocabulary entries, or Whisper-style prompt fragments).
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
|
||||
public let text: String
|
||||
public let weight: Int
|
||||
public let lang: String?
|
||||
|
||||
public init(text: String, weight: Int = 4, lang: String? = nil) {
|
||||
self.text = text
|
||||
self.weight = weight
|
||||
self.lang = lang
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary {
|
||||
/// Stable fingerprint used to decide when to refresh Alibaba vocabulary.
|
||||
public func vocabularySyncFingerprint() -> String {
|
||||
let payload = effectiveEntries
|
||||
.sorted { $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending }
|
||||
.map { entry in
|
||||
let aliases = entry.aliases
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
.joined(separator: ",")
|
||||
return "\(entry.term.lowercased())|\(aliases)"
|
||||
}
|
||||
.joined(separator: ";")
|
||||
let digest = SHA256.hash(data: Data(payload.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 智谱 `hotwords` — canonical terms only (aliases go to `asrPromptBias`).
|
||||
public func asrHotwords(maxCount: Int = 100) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var words: [String] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { continue }
|
||||
let key = term.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
words.append(term)
|
||||
if words.count >= maxCount { break }
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
/// 阿里百炼热词表 — `text` + `weight` (+ optional `lang`).
|
||||
public func alibabaHotwordEntries(maxCount: Int = 500, defaultWeight: Int = 4) -> [AlibabaHotwordEntry] {
|
||||
var seen = Set<String>()
|
||||
var entries: [AlibabaHotwordEntry] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty, term.count <= 15 else { continue }
|
||||
let key = term.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
entries.append(
|
||||
AlibabaHotwordEntry(
|
||||
text: term,
|
||||
weight: defaultWeight,
|
||||
lang: Self.inferAlibabaLang(for: term)
|
||||
)
|
||||
)
|
||||
if entries.count >= maxCount { break }
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/// Whisper / OpenAI-style short prompt bias (also used by MiMo text hint).
|
||||
public func asrPromptBias(maxCharacters: Int = 800) -> String {
|
||||
let entries = effectiveEntries
|
||||
guard !entries.isEmpty else { return "" }
|
||||
|
||||
var lines: [String] = []
|
||||
for entry in entries {
|
||||
if entry.aliases.isEmpty {
|
||||
lines.append(entry.term)
|
||||
} else {
|
||||
let aliasHint = entry.aliases.prefix(4).joined(separator: ", ")
|
||||
lines.append("\(entry.term)(常见误识别:\(aliasHint))")
|
||||
}
|
||||
let joined = lines.joined(separator: ";")
|
||||
if joined.count > maxCharacters {
|
||||
if lines.count == 1 {
|
||||
return String(joined.prefix(maxCharacters))
|
||||
}
|
||||
lines.removeLast()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard !lines.isEmpty else { return "" }
|
||||
let body = lines.joined(separator: ";")
|
||||
return "用户专有词汇,转写时请优先使用以下标准写法:\(body)"
|
||||
}
|
||||
|
||||
/// Compact domain context for Alibaba Fun-ASR Flash `input_text`.
|
||||
public func alibabaContextText(maxCharacters: Int = 1200) -> String {
|
||||
let prompt = asrPromptBias(maxCharacters: maxCharacters)
|
||||
guard !prompt.isEmpty else { return "" }
|
||||
return prompt
|
||||
}
|
||||
|
||||
private static func inferAlibabaLang(for term: String) -> String? {
|
||||
let hasNonASCII = term.unicodeScalars.contains { !$0.isASCII }
|
||||
if hasNonASCII { return "zh" }
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user