693ea8962e
Use 'version: Int = 1' instead of invalid 'version: 1' so Swift synthesizes the default initializer and PersonalDictionary() compiles. Co-authored-by: Rocky <hkgood@users.noreply.github.com>
132 lines
4.7 KiB
Swift
132 lines
4.7 KiB
Swift
// PersonalDictionary.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// User-curated list of terms the LLM must never rewrite. Persisted
|
||
// in the App Group (JSON-encoded) so both the main app's Settings
|
||
// UI and the keyboard extension's LLM call read the same data.
|
||
//
|
||
// Sources (mutually exclusive per entry):
|
||
// - `.manual` user typed it in by hand
|
||
// - `.history` auto-extracted from the user's transcription
|
||
// history by `DictionaryLearner`
|
||
// - `.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
|
||
|
||
public init(entries: [Entry] = [], version: Int = 1) {
|
||
self.entries = entries
|
||
self.version = version
|
||
}
|
||
|
||
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
|
||
public var usageCount: Int
|
||
|
||
public init(
|
||
id: UUID = UUID(),
|
||
term: String,
|
||
aliases: [String] = [],
|
||
category: Category,
|
||
source: Source,
|
||
createdAt: Date = Date(),
|
||
usageCount: Int = 0
|
||
) {
|
||
self.id = id
|
||
self.term = term
|
||
self.aliases = aliases
|
||
self.category = category
|
||
self.source = source
|
||
self.createdAt = createdAt
|
||
self.usageCount = 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 {
|
||
public static let empty = PersonalDictionary()
|
||
|
||
/// 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 {
|
||
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")
|
||
)
|
||
}
|
||
}
|