Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// RimeFrequentTermStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Small App Group sidecar for Rime commits. Reading librime's LevelDB userdb
|
||||
// while the keyboard owns it can race the engine, so the extension records
|
||||
// eligible committed terms here and the host app ranks them for suggestions.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
public var id: String { term.lowercased() }
|
||||
|
||||
public let term: String
|
||||
public let commitCount: Int
|
||||
public let firstSeenAt: Date
|
||||
public let lastSeenAt: Date
|
||||
|
||||
public init(
|
||||
term: String,
|
||||
commitCount: Int,
|
||||
firstSeenAt: Date,
|
||||
lastSeenAt: Date
|
||||
) {
|
||||
self.term = term
|
||||
self.commitCount = commitCount
|
||||
self.firstSeenAt = firstSeenAt
|
||||
self.lastSeenAt = lastSeenAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures repeated Rime candidate commits without writing to the curated
|
||||
/// PersonalDictionary until the user explicitly confirms a suggestion.
|
||||
public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "rimeTyping.frequentTerms.v1"
|
||||
public static let minimumSuggestionCount = 2
|
||||
public static let maximumTrackedTerms = 256
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let lock = NSLock()
|
||||
|
||||
public init(defaults: UserDefaults = AppGroupStore().defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func recordCommittedText(_ text: String, at date: Date = Date()) {
|
||||
guard let term = Self.normalizedCandidate(from: text) else { return }
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
var terms = loadLocked()
|
||||
let key = term.lowercased()
|
||||
if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) {
|
||||
let existing = terms[index]
|
||||
terms[index] = RimeFrequentTerm(
|
||||
term: term,
|
||||
commitCount: min(10_000, existing.commitCount + 1),
|
||||
firstSeenAt: existing.firstSeenAt,
|
||||
lastSeenAt: date
|
||||
)
|
||||
} else {
|
||||
terms.append(
|
||||
RimeFrequentTerm(
|
||||
term: term,
|
||||
commitCount: 1,
|
||||
firstSeenAt: date,
|
||||
lastSeenAt: date
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
terms.sort { lhs, rhs in
|
||||
if lhs.lastSeenAt != rhs.lastSeenAt {
|
||||
return lhs.lastSeenAt > rhs.lastSeenAt
|
||||
}
|
||||
return lhs.commitCount > rhs.commitCount
|
||||
}
|
||||
saveLocked(Array(terms.prefix(Self.maximumTrackedTerms)))
|
||||
}
|
||||
|
||||
/// Repeated, recent Rime commits that are not already curated.
|
||||
public func suggestions(
|
||||
excludingPersonalTerms personalTerms: Set<String>,
|
||||
limit: Int = 5
|
||||
) -> [RimeFrequentTerm] {
|
||||
guard limit > 0 else { return [] }
|
||||
let excluded = Set(personalTerms.map { $0.lowercased() })
|
||||
|
||||
lock.lock()
|
||||
let terms = loadLocked()
|
||||
lock.unlock()
|
||||
|
||||
return terms
|
||||
.filter {
|
||||
$0.commitCount >= Self.minimumSuggestionCount
|
||||
&& !excluded.contains($0.term.lowercased())
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.commitCount != rhs.commitCount {
|
||||
return lhs.commitCount > rhs.commitCount
|
||||
}
|
||||
if lhs.lastSeenAt != rhs.lastSeenAt {
|
||||
return lhs.lastSeenAt > rhs.lastSeenAt
|
||||
}
|
||||
return lhs.term.localizedStandardCompare(rhs.term) == .orderedAscending
|
||||
}
|
||||
.prefix(limit)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
defaults.removeObject(forKey: Self.defaultsKey)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func normalizedCandidate(from text: String) -> String? {
|
||||
let term = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard (2...12).contains(term.count),
|
||||
!commonTerms.contains(term),
|
||||
!term.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var semanticCharacterCount = 0
|
||||
for scalar in term.unicodeScalars {
|
||||
if HanScript.isIdeograph(scalar)
|
||||
|| (scalar.isASCII && CharacterSet.alphanumerics.contains(scalar)) {
|
||||
semanticCharacterCount += 1
|
||||
continue
|
||||
}
|
||||
// Product names and proper nouns commonly contain these separators.
|
||||
guard allowedSeparators.contains(Character(String(scalar))) else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return semanticCharacterCount >= 2 ? term : nil
|
||||
}
|
||||
|
||||
private func loadLocked() -> [RimeFrequentTerm] {
|
||||
guard let data = defaults.data(forKey: Self.defaultsKey),
|
||||
let terms = try? JSONDecoder().decode([RimeFrequentTerm].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
private func saveLocked(_ terms: [RimeFrequentTerm]) {
|
||||
guard let data = try? JSONEncoder().encode(terms) else { return }
|
||||
defaults.set(data, forKey: Self.defaultsKey)
|
||||
}
|
||||
|
||||
/// Avoid recommending ubiquitous conversational glue as a personal term.
|
||||
private static let commonTerms: Set<String> = [
|
||||
"一个", "一下", "不会", "不是", "什么", "他们", "但是", "你们", "你好",
|
||||
"可能", "可以", "因为", "好的", "如果", "已经", "应该", "怎么", "我们",
|
||||
"所以", "时候", "明天", "昨天", "有点", "没有", "然后", "现在", "知道",
|
||||
"自己", "觉得", "谢谢", "这个", "这里", "这样", "还是", "还有", "那个",
|
||||
"那里", "那样", "需要", "今天", "就是"
|
||||
]
|
||||
|
||||
private static let allowedSeparators: Set<Character> = ["-", ".", "+", "#", "·"]
|
||||
}
|
||||
@@ -10,9 +10,11 @@ public enum TypingHabitStore {
|
||||
/// Clears English boosts and Chinese Rime user dictionaries.
|
||||
/// Does not touch PersonalDictionary / osg_personal.
|
||||
public static func clearAll(
|
||||
englishStore: EnglishLearningStore = EnglishLearningStore()
|
||||
englishStore: EnglishLearningStore = EnglishLearningStore(),
|
||||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||||
) async throws {
|
||||
englishStore.clear()
|
||||
rimeFrequentTermStore.clear()
|
||||
try await RimeResourceInstaller.shared.clearUserDictionary()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
private let engineFactory: @MainActor () -> RimeEngineBridging
|
||||
private let englishFactory: @MainActor () -> EnglishSuggestionEngine
|
||||
private let learningStore: EnglishLearningStore
|
||||
private let rimeFrequentTermStore: RimeFrequentTermStore
|
||||
private var engineStorage: RimeEngineBridging?
|
||||
private var englishStorage: EnglishSuggestionEngine?
|
||||
private var prepared = false
|
||||
@@ -97,12 +98,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||
layout: TypingLayoutProviding = StandardTypingLayout(),
|
||||
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore()
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore(),
|
||||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||||
) {
|
||||
self.engineFactory = engine ?? { LibrimeEngine() }
|
||||
self.layout = layout
|
||||
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
|
||||
self.learningStore = learningStore
|
||||
self.rimeFrequentTermStore = rimeFrequentTermStore
|
||||
// Avoid constructing librime until the first Chinese keystroke / prepare.
|
||||
schema = TypingInputConfiguration.shared.schema
|
||||
}
|
||||
@@ -368,6 +371,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
clearOneShotShiftIfNeeded()
|
||||
recordRimeCommit(committed)
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
@@ -383,6 +387,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
@@ -398,6 +403,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
@@ -417,9 +423,15 @@ public final class TypingSessionController: ObservableObject {
|
||||
// Selecting always collapses; follow-up composition may reopen ▼.
|
||||
isCandidatePanelExpanded = false
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
return text.isEmpty ? .none : .insert(text)
|
||||
}
|
||||
|
||||
private func recordRimeCommit(_ text: String) {
|
||||
guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return }
|
||||
rimeFrequentTermStore.recordCommittedText(text)
|
||||
}
|
||||
|
||||
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
|
||||
private func abandonChineseComposition() {
|
||||
engineStorage?.clearComposition()
|
||||
|
||||
Reference in New Issue
Block a user