380 lines
14 KiB
Swift
380 lines
14 KiB
Swift
// PersonalDictionary.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// User-curated list of terms shared across polish, ASR bias, English
|
||
// typing hotwords, and (via host Rime redeploy) Chinese Pinyin candidates.
|
||
// Persisted in the App Group (JSON-encoded) so the main app, keyboard
|
||
// extension, and host deploy pipeline read the same data.
|
||
//
|
||
// Sources (mutually exclusive per entry):
|
||
// - `.manual` user typed it in by hand
|
||
// - `.history` user-confirmed recommendations from typing history
|
||
// - `.contacts` imported from the iOS Contacts framework
|
||
// - `.recentEdit` extracted from edits the user made to a
|
||
// polished transcript before sending
|
||
//
|
||
// The dictionary is intentionally read-mostly: writes only happen
|
||
// from the main app (or from a low-frequency background task). The
|
||
// keyboard extension never writes to it.
|
||
|
||
import Foundation
|
||
|
||
public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||
public var entries: [Entry]
|
||
public var version: Int
|
||
/// When this dictionary blob was last successfully pushed to iCloud KVS.
|
||
public var lastSyncedAt: Date?
|
||
/// Tombstones for deleted entries — prevents remote resurrections.
|
||
public var deletedEntryIDs: [UUID: Date]
|
||
/// When set, entries created at or before this instant are excluded from merge.
|
||
public var clearedAt: Date?
|
||
|
||
public init(
|
||
entries: [Entry] = [],
|
||
version: Int = 1,
|
||
lastSyncedAt: Date? = nil,
|
||
deletedEntryIDs: [UUID: Date] = [:],
|
||
clearedAt: Date? = nil
|
||
) {
|
||
self.entries = entries
|
||
self.version = version
|
||
self.lastSyncedAt = lastSyncedAt
|
||
self.deletedEntryIDs = deletedEntryIDs
|
||
self.clearedAt = clearedAt
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case entries
|
||
case version
|
||
case lastSyncedAt
|
||
case deletedEntryIDs
|
||
case clearedAt
|
||
}
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
|
||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
|
||
deletedEntryIDs = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) ?? [:]
|
||
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
|
||
}
|
||
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||
try container.encode(entries, forKey: .entries)
|
||
try container.encode(version, forKey: .version)
|
||
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
|
||
if !deletedEntryIDs.isEmpty {
|
||
try container.encode(deletedEntryIDs, forKey: .deletedEntryIDs)
|
||
}
|
||
try container.encodeIfPresent(clearedAt, forKey: .clearedAt)
|
||
}
|
||
|
||
public struct Entry: Codable, Sendable, Equatable, Identifiable {
|
||
public let id: UUID
|
||
public var term: String
|
||
public var aliases: [String]
|
||
public var category: Category
|
||
public var source: Source
|
||
public var createdAt: Date
|
||
/// Last mutation time — used for iCloud merge conflict resolution.
|
||
public var updatedAt: Date
|
||
public var usageCount: Int
|
||
|
||
public init(
|
||
id: UUID = UUID(),
|
||
term: String,
|
||
aliases: [String] = [],
|
||
category: Category,
|
||
source: Source,
|
||
createdAt: Date = Date(),
|
||
updatedAt: Date? = nil,
|
||
usageCount: Int = 0
|
||
) {
|
||
self.id = id
|
||
self.term = term
|
||
self.aliases = aliases
|
||
self.category = category
|
||
self.source = source
|
||
self.createdAt = createdAt
|
||
self.updatedAt = updatedAt ?? createdAt
|
||
self.usageCount = usageCount
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case id
|
||
case term
|
||
case aliases
|
||
case category
|
||
case source
|
||
case createdAt
|
||
case updatedAt
|
||
case usageCount
|
||
}
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try container.decode(UUID.self, forKey: .id)
|
||
term = try container.decode(String.self, forKey: .term)
|
||
aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
|
||
category = try container.decode(Category.self, forKey: .category)
|
||
source = try container.decode(Source.self, forKey: .source)
|
||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
|
||
usageCount = try container.decodeIfPresent(Int.self, forKey: .usageCount) ?? 0
|
||
}
|
||
|
||
public func encode(to encoder: Encoder) throws {
|
||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||
try container.encode(id, forKey: .id)
|
||
try container.encode(term, forKey: .term)
|
||
try container.encode(aliases, forKey: .aliases)
|
||
try container.encode(category, forKey: .category)
|
||
try container.encode(source, forKey: .source)
|
||
try container.encode(createdAt, forKey: .createdAt)
|
||
try container.encode(updatedAt, forKey: .updatedAt)
|
||
try container.encode(usageCount, forKey: .usageCount)
|
||
}
|
||
|
||
public enum Category: String, Codable, Sendable, CaseIterable {
|
||
/// Person / place / brand / organization.
|
||
case properNoun
|
||
/// API, framework, library, language, file format.
|
||
case technical
|
||
/// Initialism like LLM, iOS, ML.
|
||
case acronym
|
||
/// Product name (Typeless, OSGKeyboard, ChatGPT).
|
||
case productName
|
||
/// Anything that does not fit the above.
|
||
case custom
|
||
|
||
public var labelKey: String {
|
||
switch self {
|
||
case .properNoun: return "dict.category.properNoun"
|
||
case .technical: return "dict.category.technical"
|
||
case .acronym: return "dict.category.acronym"
|
||
case .productName: return "dict.category.productName"
|
||
case .custom: return "dict.category.custom"
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum Source: String, Codable, Sendable, CaseIterable {
|
||
case manual
|
||
case history
|
||
case contacts
|
||
case recentEdit
|
||
|
||
public var labelKey: String {
|
||
switch self {
|
||
case .manual: return "dict.source.manual"
|
||
case .history: return "dict.source.history"
|
||
case .contacts: return "dict.source.contacts"
|
||
case .recentEdit: return "dict.source.recentEdit"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Renders the entry for the LLM prompt. Includes aliases
|
||
/// in parentheses so the LLM recognizes voice variants
|
||
/// ("k8s" → "Kubernetes") without renaming.
|
||
public func promptFragment() -> String {
|
||
if aliases.isEmpty { return term }
|
||
return "\(term)(\(aliases.joined(separator: " / ")))"
|
||
}
|
||
}
|
||
}
|
||
|
||
extension PersonalDictionary.Entry {
|
||
/// Lightweight category inference for manual adds and the history
|
||
/// learner. Users can re-classify later from Settings.
|
||
public static func inferCategory(for term: String) -> Category {
|
||
let hasUpper = term.contains(where: { $0.isUppercase })
|
||
let hasDigit = term.unicodeScalars.contains { scalar in
|
||
CharacterSet.decimalDigits.contains(scalar) && scalar.isASCII
|
||
}
|
||
let hasLatin = term.unicodeScalars.contains { scalar in
|
||
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
||
}
|
||
if hasUpper, !term.contains(where: { $0.isLowercase }) {
|
||
return .acronym
|
||
}
|
||
if hasDigit {
|
||
return .productName
|
||
}
|
||
if !hasLatin {
|
||
return .properNoun
|
||
}
|
||
return .productName
|
||
}
|
||
}
|
||
|
||
extension PersonalDictionary {
|
||
public static let empty = PersonalDictionary()
|
||
|
||
/// Built-in terms always included in LLM prompts. Never persisted
|
||
/// and never shown in the Settings personal-dictionary UI.
|
||
public static let systemEntries: [Entry] = [
|
||
Entry(
|
||
id: UUID(uuidString: "A0000000-0000-4000-8000-000000000001")!,
|
||
term: "OSGKeyboard",
|
||
aliases: [],
|
||
category: .productName,
|
||
source: .manual,
|
||
createdAt: Date(timeIntervalSince1970: 0),
|
||
updatedAt: Date(timeIntervalSince1970: 0),
|
||
usageCount: 0
|
||
)
|
||
]
|
||
|
||
/// User entries plus built-in system terms (deduped by term).
|
||
public var effectiveEntries: [Entry] {
|
||
var merged = Self.systemEntries
|
||
let systemTerms = Set(Self.systemEntries.map { $0.term.lowercased() })
|
||
for entry in entries where !systemTerms.contains(entry.term.lowercased()) {
|
||
merged.append(entry)
|
||
}
|
||
return merged
|
||
}
|
||
|
||
/// Personal hotwords safe for the English typing keyboard.
|
||
/// Drops CJK terms; keeps Latin product names / acronyms (and English aliases).
|
||
public func englishTypingHotwords() -> [String] {
|
||
var seen = Set<String>()
|
||
var words: [String] = []
|
||
for entry in effectiveEntries {
|
||
for raw in ([entry.term] + entry.aliases) {
|
||
let term = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard Self.isEnglishTypingHotword(term) else { continue }
|
||
let key = term.lowercased()
|
||
guard seen.insert(key).inserted else { continue }
|
||
words.append(term)
|
||
}
|
||
}
|
||
return words
|
||
}
|
||
|
||
/// Latin-script hotword with no CJK ideographs (allows digits / hyphen / apostrophe).
|
||
public static func isEnglishTypingHotword(_ term: String) -> Bool {
|
||
guard !term.isEmpty else { return false }
|
||
var hasLatinLetter = false
|
||
for scalar in term.unicodeScalars {
|
||
if HanScript.isIdeograph(scalar) { return false }
|
||
if scalar.isASCII, CharacterSet.letters.contains(scalar) {
|
||
hasLatinLetter = true
|
||
}
|
||
}
|
||
return hasLatinLetter
|
||
}
|
||
|
||
/// Case-insensitive lookup by canonical term.
|
||
public func entry(matchingTerm term: String) -> Entry? {
|
||
let key = term.lowercased()
|
||
return entries.first { $0.term.lowercased() == key }
|
||
}
|
||
|
||
/// Insert or update a manual entry. Kept as a convenience for existing callers.
|
||
@discardableResult
|
||
public mutating func upsertManual(
|
||
term: String,
|
||
existingID: UUID? = nil,
|
||
regenerateAliases: Bool = false
|
||
) -> Entry? {
|
||
upsert(
|
||
term: term,
|
||
existingID: existingID,
|
||
source: .manual,
|
||
regenerateAliases: regenerateAliases
|
||
)
|
||
}
|
||
|
||
/// Insert or update an entry while preserving its user-visible origin.
|
||
@discardableResult
|
||
public mutating func upsert(
|
||
term: String,
|
||
existingID: UUID? = nil,
|
||
source: Entry.Source,
|
||
regenerateAliases: Bool = false
|
||
) -> Entry? {
|
||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
|
||
let category = Entry.inferCategory(for: trimmed)
|
||
|
||
if let existingID,
|
||
let idx = entries.firstIndex(where: { $0.id == existingID }) {
|
||
var entry = entries[idx]
|
||
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
|
||
entry.term = trimmed
|
||
entry.category = category
|
||
entry.source = source
|
||
if termChanged || regenerateAliases {
|
||
entry.aliases = []
|
||
}
|
||
entry.updatedAt = Date()
|
||
entries[idx] = entry
|
||
return entry
|
||
}
|
||
|
||
if let idx = entries.firstIndex(where: {
|
||
$0.term.caseInsensitiveCompare(trimmed) == .orderedSame
|
||
}) {
|
||
var entry = entries[idx]
|
||
entry.term = trimmed
|
||
entry.category = category
|
||
entry.source = source
|
||
entry.updatedAt = Date()
|
||
entries[idx] = entry
|
||
return entry
|
||
}
|
||
|
||
let now = Date()
|
||
let entry = Entry(
|
||
term: trimmed,
|
||
aliases: [],
|
||
category: category,
|
||
source: source,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
entries.append(entry)
|
||
return entry
|
||
}
|
||
|
||
public mutating func updateAliases(for entryID: UUID, aliases: [String]) {
|
||
guard let idx = entries.firstIndex(where: { $0.id == entryID }) else { return }
|
||
let cleaned = aliases
|
||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
.filter { !$0.isEmpty }
|
||
let termLower = entries[idx].term.lowercased()
|
||
entries[idx].aliases = Array(
|
||
Set(cleaned.filter { $0.lowercased() != termLower })
|
||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||
entries[idx].updatedAt = Date()
|
||
}
|
||
|
||
/// Renders the entire dictionary as a prompt fragment. Entries
|
||
/// are grouped by category so the LLM can scan quickly. Empty
|
||
/// dictionary returns "" so the caller can blindly concatenate.
|
||
public func promptFragment() -> String {
|
||
let entries = effectiveEntries
|
||
guard !entries.isEmpty else { return "" }
|
||
let grouped = Dictionary(grouping: entries, by: { $0.category })
|
||
var lines: [String] = []
|
||
for category in Entry.Category.allCases {
|
||
guard let bucket = grouped[category], !bucket.isEmpty else { continue }
|
||
let terms = bucket
|
||
.sorted { $0.usageCount > $1.usageCount }
|
||
.map { $0.promptFragment() }
|
||
.joined(separator: "、")
|
||
lines.append("【\(category.rawValue)】\(terms)")
|
||
}
|
||
guard !lines.isEmpty else { return "" }
|
||
return (
|
||
"以下为用户专有词汇,**必须**原样保留,**绝不**改写或翻译:" +
|
||
"\n" + lines.joined(separator: "\n")
|
||
)
|
||
}
|
||
}
|