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:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
@@ -1,22 +1,19 @@
// AIAgentSkillLayout.swift
// OSGKeyboard · Shared
//
// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
// the user confirmed a companion Shortcut for. Missing storage hydrates
// to the three default transform skills; an explicit empty list is kept
// Enabled clipboard skills (ordered) plus which export skills the user
// confirmed a companion Shortcut for. Missing storage hydrates to every
// built-in default skill; an explicit empty list is kept
// so turning every skill off is distinct from a fresh install.
import Foundation
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
public static let maximumEnabled = 8
public static let defaultEnabledIDs = [
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.translateID
]
public static let defaultEnabledIDs = AIClipboardSkillCatalog.catalog
.filter(\.isDefault)
.map(\.id)
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
/// Keyboard chip order. Unknown and duplicate IDs are dropped on sanitize.
public var enabledIDs: [String]
/// Export skills whose companion Shortcut the user marked as added.
public var confirmedShortcutIDs: [String]
@@ -31,10 +28,6 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
self.confirmedShortcutIDs = confirmedShortcutIDs
}
public var isFull: Bool {
enabledIDs.count >= Self.maximumEnabled
}
public func isEnabled(_ id: String) -> Bool {
enabledIDs.contains(id)
}
@@ -43,20 +36,16 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
confirmedShortcutIDs.contains(id)
}
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
/// Drops unknown IDs and duplicates. Shortcut setup is tracked separately
/// so bundled export skills can remain visible as installed defaults.
public func sanitized(
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
) -> AIAgentSkillLayout {
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
var seenEnabled = Set<String>()
let enabled = enabledIDs.filter { id in
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
if skill.requiresShortcut {
return confirmedShortcutIDs.contains(id)
}
return true
known[id] != nil && seenEnabled.insert(id).inserted
}
.prefix(Self.maximumEnabled)
var seenConfirmed = Set<String>()
let confirmed = confirmedShortcutIDs.filter { id in
@@ -65,7 +54,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
}
return AIAgentSkillLayout(
enabledIDs: Array(enabled),
enabledIDs: enabled,
confirmedShortcutIDs: confirmed
)
}
@@ -75,6 +64,5 @@ public enum AIAgentSkillEnableResult: Equatable, Sendable {
case enabled
case alreadyEnabled
case needsShortcut
case full
case unknown
}
+10 -3
View File
@@ -167,13 +167,13 @@ public struct AIHintManifest: Codable, Equatable, Sendable {
}
public enum AIHintFeedEndpoints {
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
public static let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
public static let manifestURL = baseURL.appendingPathComponent("manifest")
/// Packs the app fetches and the keyboard can resolve.
public static let supportedLocales = ["zh", "en"]
public static func packURL(locale: String) -> URL {
baseURL.appendingPathComponent("hints-\(locale).json")
baseURL.appendingPathComponent(locale)
}
}
@@ -194,6 +194,9 @@ public enum AIHintAppGroupKeys {
public static let readyPackPrefix = "hints.ready."
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
public static let manifest = "hints.meta.manifest.v1"
public static let manifestETag = "hints.meta.manifestETag.v1"
public static let packETagPrefix = "hints.meta.packETag."
public static func readyPackKey(locale: String) -> String {
readyPackPrefix + locale
@@ -203,4 +206,8 @@ public enum AIHintAppGroupKeys {
public static func lastSuccessKey(locale: String) -> String {
lastSuccessPrefix + locale
}
public static func packETagKey(locale: String) -> String {
packETagPrefix + locale
}
}
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let engineMode = "config.engineMode"
/// Credential ownership is independent from local/cloud ASR selection.
public static let credentialSource = "config.credentialSource"
/// Device-local, non-secret gate for account-funded managed requests.
/// This key is deliberately excluded from iCloud settings payloads.
public static let managedGatewayAccountSessionAvailable =
"account.managedGateway.sessionAvailable.v1"
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
public static let onboardingPage = "config.onboardingPage"
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
@@ -73,8 +77,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
/// One-shot: append semantic built-ins introduced with the unlimited layout.
public static let agentSkillDefaultsMigrationVersion =
"config.aiAgentSkills.defaultsMigrationVersion"
/// User-created clipboard skills (no cloud sync; App Group only).
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
/// Last-known-good official catalog plus revision, freshness, and ETag metadata.
public static let officialSkillCatalog = "content.officialSkills.snapshot.v1"
}
// MARK: - Stored fields
@@ -195,13 +204,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public func makeClient(
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil
) -> LLMClient {
if credentialSource == .managed {
if credentialSource == .managed || requestPurpose == .oobe {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: GatewayGrantCoordinator()
)
}
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
case submitAIQuestion
}
/// Wire version that includes managed-gateway request purpose.
public static let currentProtocolVersion = 7
/// Wire version that includes a strongly typed OOBE feature.
public static let currentProtocolVersion = 8
public let protocolVersion: Int
public let sessionId: UUID
@@ -45,6 +45,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiTaskKind: ManagedGatewayTaskKind?
/// Optional server-audited purpose for managed gateway billing policy.
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
@@ -68,6 +70,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
@@ -88,6 +91,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.managedRequestPurpose = managedRequestPurpose
self.managedOOBEFeature = managedOOBEFeature
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
@@ -17,6 +17,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiTaskKind: ManagedGatewayTaskKind?
/// Optional server-audited purpose for managed gateway billing policy.
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
@@ -31,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
@@ -41,6 +44,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.managedRequestPurpose = managedRequestPurpose
self.managedOOBEFeature = managedOOBEFeature
self.aiThinkingEnabled = aiThinkingEnabled
}
@@ -62,6 +66,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
conversationID: UUID,
prefilledQuestion: String? = nil,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
@@ -69,6 +74,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion,
aiTaskKind: taskKind,
managedRequestPurpose: oobeFeature == nil ? nil : .oobe,
managedOOBEFeature: oobeFeature,
aiThinkingEnabled: thinkingEnabled
)
}
@@ -0,0 +1,273 @@
// OfficialSkillCatalog.swift
// OSGKeyboard · Shared
//
// Validated, host-fetched official clipboard skills. The keyboard extension
// only reads the last-known-good App Group snapshot and never performs network work.
import Foundation
public struct OfficialSkillLocalization: Codable, Equatable, Sendable {
public let name: String
public let summary: String
public let prompt: String
public init(name: String, summary: String, prompt: String) {
self.name = name
self.summary = summary
self.prompt = prompt
}
}
public struct OfficialSkillDefinition: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let systemImage: String
public let sortOrder: Int
public let kind: AIClipboardSkillKind
public let thinkingEnabled: Bool
public let localizations: [String: OfficialSkillLocalization]
public init(
id: String,
systemImage: String,
sortOrder: Int,
kind: AIClipboardSkillKind,
thinkingEnabled: Bool,
localizations: [String: OfficialSkillLocalization]
) {
self.id = id
self.systemImage = systemImage
self.sortOrder = sortOrder
self.kind = kind
self.thinkingEnabled = thinkingEnabled
self.localizations = localizations
}
public func localization(
language: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> OfficialSkillLocalization {
localization(locale: language.resolvedLanguageCode(preferredLanguages: preferredLanguages))
}
public func localization(locale: String) -> OfficialSkillLocalization {
let normalized = locale.lowercased()
let key = normalized.hasPrefix("zh") ? "zh-Hans" : "en"
// A validated definition always contains both keys. Keep an English
// fallback so a corrupt in-memory fixture cannot crash the extension.
return localizations[key] ?? localizations["en"] ?? OfficialSkillLocalization(
name: id,
summary: "",
prompt: ""
)
}
public func asClipboardSkill(
language: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> AIClipboardSkill {
asClipboardSkill(
localization: localization(
language: language,
preferredLanguages: preferredLanguages
)
)
}
public func asClipboardSkill(locale: String) -> AIClipboardSkill {
asClipboardSkill(localization: localization(locale: locale))
}
private func asClipboardSkill(localization: OfficialSkillLocalization) -> AIClipboardSkill {
AIClipboardSkill(
id: id,
systemImage: systemImage,
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: kind,
isDefault: false,
customName: localization.name,
customSummary: localization.summary,
customPrompt: localization.prompt,
thinkingEnabled: thinkingEnabled
)
}
}
public enum OfficialSkillCatalogValidationError: Error, Equatable, Sendable {
case unsupportedSchemaVersion(Int)
case invalidRevision
case invalidGeneratedAt
case tooManySkills(maximum: Int)
case invalidID(String)
case duplicateID(String)
case conflictsWithBuiltInID(String)
case invalidSystemImage(String)
case invalidSortOrder(String)
case unsupportedKind(String)
case invalidLocalizations(String)
case emptyName(String, locale: String)
case emptySummary(String, locale: String)
case emptyPrompt(String, locale: String)
case promptTooLong(String, locale: String, maximum: Int)
}
public struct OfficialSkillCatalog: Codable, Equatable, Sendable {
public static let supportedSchemaVersion = 1
public static let maximumSkillCount = 100
public static let maximumIDCharacters = 100
public static let maximumSystemImageCharacters = 100
public static let maximumSortOrder = 100_000
public static let maximumNameCharacters = 40
public static let maximumSummaryCharacters = 200
public static let maximumPromptCharacters = 6_000
public let schemaVersion: Int
public let revision: Int64
public let generatedAt: String?
public let skills: [OfficialSkillDefinition]
/// Host wall clock for cache freshness. Absent in the server response.
public var refreshedAt: Date?
/// Last response ETag. Absent in the server response.
public var etag: String?
public init(
schemaVersion: Int = supportedSchemaVersion,
revision: Int64,
generatedAt: String? = nil,
skills: [OfficialSkillDefinition],
refreshedAt: Date? = nil,
etag: String? = nil
) {
self.schemaVersion = schemaVersion
self.revision = revision
self.generatedAt = generatedAt
self.skills = skills
self.refreshedAt = refreshedAt
self.etag = etag
}
public static let empty = OfficialSkillCatalog(revision: 0, skills: [])
public func validated(
builtInIDs: Set<String> = Set(AIClipboardSkillCatalog.catalog.map(\.id))
) throws -> OfficialSkillCatalog {
guard schemaVersion == Self.supportedSchemaVersion else {
throw OfficialSkillCatalogValidationError.unsupportedSchemaVersion(schemaVersion)
}
guard revision >= 0 else {
throw OfficialSkillCatalogValidationError.invalidRevision
}
if let generatedAt, Self.iso8601Date(from: generatedAt) == nil {
throw OfficialSkillCatalogValidationError.invalidGeneratedAt
}
guard skills.count <= Self.maximumSkillCount else {
throw OfficialSkillCatalogValidationError.tooManySkills(
maximum: Self.maximumSkillCount
)
}
var seen = Set<String>()
for skill in skills {
guard Self.isValidID(skill.id) else {
throw OfficialSkillCatalogValidationError.invalidID(skill.id)
}
guard seen.insert(skill.id).inserted else {
throw OfficialSkillCatalogValidationError.duplicateID(skill.id)
}
guard !builtInIDs.contains(skill.id) else {
throw OfficialSkillCatalogValidationError.conflictsWithBuiltInID(skill.id)
}
let image = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !image.isEmpty, image.count <= Self.maximumSystemImageCharacters else {
throw OfficialSkillCatalogValidationError.invalidSystemImage(skill.id)
}
guard (0...Self.maximumSortOrder).contains(skill.sortOrder) else {
throw OfficialSkillCatalogValidationError.invalidSortOrder(skill.id)
}
guard skill.kind == .transform else {
throw OfficialSkillCatalogValidationError.unsupportedKind(skill.id)
}
guard Set(skill.localizations.keys) == ["zh-Hans", "en"] else {
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
}
for locale in ["zh-Hans", "en"] {
guard let localization = skill.localizations[locale] else {
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
}
let name = localization.name.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = localization.summary.trimmingCharacters(in: .whitespacesAndNewlines)
let prompt = localization.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= Self.maximumNameCharacters else {
throw OfficialSkillCatalogValidationError.emptyName(skill.id, locale: locale)
}
guard !summary.isEmpty,
summary.count <= Self.maximumSummaryCharacters else {
throw OfficialSkillCatalogValidationError.emptySummary(skill.id, locale: locale)
}
guard !prompt.isEmpty else {
throw OfficialSkillCatalogValidationError.emptyPrompt(skill.id, locale: locale)
}
guard prompt.count <= Self.maximumPromptCharacters else {
throw OfficialSkillCatalogValidationError.promptTooLong(
skill.id,
locale: locale,
maximum: Self.maximumPromptCharacters
)
}
}
}
return OfficialSkillCatalog(
schemaVersion: schemaVersion,
revision: revision,
generatedAt: generatedAt,
skills: skills.sorted {
if $0.sortOrder == $1.sortOrder { return $0.id < $1.id }
return $0.sortOrder < $1.sortOrder
},
refreshedAt: refreshedAt,
etag: etag
)
}
public func resolvedSkills(
language: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> [AIClipboardSkill] {
skills.map {
$0.asClipboardSkill(
language: language,
preferredLanguages: preferredLanguages
)
}
}
public func resolvedSkills(locale: String) -> [AIClipboardSkill] {
skills.map { $0.asClipboardSkill(locale: locale) }
}
private static func isValidID(_ id: String) -> Bool {
guard id.hasPrefix("official."), id.count <= Self.maximumIDCharacters else {
return false
}
let suffix = id.dropFirst("official.".count)
guard !suffix.isEmpty else { return false }
return suffix.unicodeScalars.allSatisfy { scalar in
let value = scalar.value
return (97...122).contains(value)
|| (48...57).contains(value)
|| value == 46
|| value == 45
|| value == 95
}
}
private static func iso8601Date(from value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
}
@@ -12,7 +12,18 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
}
public let id: UUID
/// Final text delivered to the host app and shown in history.
public let text: String
/// ASR transcript after deterministic correction but before LLM polishing.
/// Stored for future speaking-style learning and intentionally not shown.
public let prePolishText: String?
/// Translation outputs must not be treated as same-language style examples.
public let wasTranslation: Bool
/// Style active when the final text was produced; nil for legacy or AI rows.
public let polishStyleID: String?
/// SHA-256 key into `SyncedSpeechHistory.polishStylePromptSnapshots`.
/// Deduplication preserves the exact prompt without repeating it per row.
public let polishStylePromptFingerprint: String?
public let createdAt: Date
/// Last content mutation. Legacy rows default to `createdAt`.
public let modifiedAt: Date
@@ -26,6 +37,10 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
public init(
id: UUID = UUID(),
text: String,
prePolishText: String? = nil,
wasTranslation: Bool = false,
polishStyleID: String? = nil,
polishStylePromptFingerprint: String? = nil,
createdAt: Date = Date(),
modifiedAt: Date? = nil,
revision: Int64 = 0,
@@ -34,6 +49,14 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
) {
self.id = id
self.text = text
let trimmedPrePolishText = prePolishText?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.prePolishText = trimmedPrePolishText?.isEmpty == false
? trimmedPrePolishText
: nil
self.wasTranslation = wasTranslation
self.polishStyleID = polishStyleID
self.polishStylePromptFingerprint = polishStylePromptFingerprint
self.createdAt = createdAt
self.modifiedAt = modifiedAt ?? createdAt
self.revision = revision
@@ -45,6 +68,13 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
text = try container.decode(String.self, forKey: .text)
prePolishText = try container.decodeIfPresent(String.self, forKey: .prePolishText)
wasTranslation = try container.decodeIfPresent(Bool.self, forKey: .wasTranslation) ?? false
polishStyleID = try container.decodeIfPresent(String.self, forKey: .polishStyleID)
polishStylePromptFingerprint = try container.decodeIfPresent(
String.self,
forKey: .polishStylePromptFingerprint
)
createdAt = try container.decode(Date.self, forKey: .createdAt)
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
@@ -4,10 +4,11 @@
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
// propagate single-entry deletes and "clear all" across devices.
import CryptoKit
import Foundation
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public static let schemaVersion = 3
public static let schemaVersion = 5
public static let kvsKey = "speechHistory.v2"
public static let legacyKVSKey = "speechHistory.v1"
public static let maxEntries = 300
@@ -26,17 +27,24 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public var schemaVersion: Int
public var updatedAt: Date
public var entries: [SpeechHistoryEntry]
/// Deduplicated historical style prompts keyed by SHA-256. Keeping prompt
/// snapshots outside rows avoids multiplying a 6,000-character prompt by
/// every dictation while still preserving the prompt used at that moment.
public var polishStylePromptSnapshots: [String: String]
/// Entry IDs deleted on any device, with deletion timestamps.
public var deletedEntryIDs: [UUID: Date]
/// Recent idempotency keys for keyboard-originated history mutations.
public var appliedMutationIDs: [UUID]
/// When set, entries created at or before this instant are excluded.
public var clearedAt: Date?
public static let maxPolishStylePromptSnapshots = 32
public static let maxPolishStylePromptSnapshotCharacters = 96_000
public init(
schemaVersion: Int = Self.schemaVersion,
updatedAt: Date = Date(),
entries: [SpeechHistoryEntry] = [],
polishStylePromptSnapshots: [String: String] = [:],
deletedEntryIDs: [UUID: Date] = [:],
appliedMutationIDs: [UUID] = [],
clearedAt: Date? = nil
@@ -44,6 +52,7 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
self.schemaVersion = schemaVersion
self.updatedAt = updatedAt
self.entries = entries
self.polishStylePromptSnapshots = polishStylePromptSnapshots
self.deletedEntryIDs = deletedEntryIDs
self.appliedMutationIDs = appliedMutationIDs
self.clearedAt = clearedAt
@@ -54,6 +63,10 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
polishStylePromptSnapshots = try container.decodeIfPresent(
[String: String].self,
forKey: .polishStylePromptSnapshots
) ?? [:]
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
deletedEntryIDs = map
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
@@ -105,20 +118,30 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
entries = Array(entries.prefix(maxEntries))
}
return SyncedSpeechHistory(
var snapshots = local.polishStylePromptSnapshots
for (fingerprint, prompt) in remote.polishStylePromptSnapshots {
snapshots[fingerprint] = snapshots[fingerprint] ?? prompt
}
var merged = SyncedSpeechHistory(
updatedAt: max(local.updatedAt, remote.updatedAt),
entries: entries,
polishStylePromptSnapshots: snapshots,
deletedEntryIDs: deletedIDs,
appliedMutationIDs: appliedMutationIDs,
clearedAt: clearedAt
)
merged.prunePolishStylePromptSnapshots()
return merged
}
/// Trim to the newest `maxEntries` rows (call after local-only appends).
public mutating func trimEntries() {
guard entries.count > Self.maxEntries else { return }
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
updatedAt = Date()
if entries.count > Self.maxEntries {
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
updatedAt = Date()
}
prunePolishStylePromptSnapshots()
}
public mutating func pruneTombstonesIfNeeded() {
@@ -162,19 +185,93 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
_ candidate: SpeechHistoryEntry,
over existing: SpeechHistoryEntry
) -> SpeechHistoryEntry {
let winner: SpeechHistoryEntry
let fallback: SpeechHistoryEntry
if candidate.revision != existing.revision {
return candidate.revision > existing.revision ? candidate : existing
(winner, fallback) = candidate.revision > existing.revision
? (candidate, existing)
: (existing, candidate)
} else if candidate.modifiedAt != existing.modifiedAt {
(winner, fallback) = candidate.modifiedAt > existing.modifiedAt
? (candidate, existing)
: (existing, candidate)
} else {
(winner, fallback) = candidate.createdAt >= existing.createdAt
? (candidate, existing)
: (existing, candidate)
}
if candidate.modifiedAt != existing.modifiedAt {
return candidate.modifiedAt > existing.modifiedAt ? candidate : existing
return preservingCorpusMetadata(of: winner, fallback: fallback)
}
/// Older app versions decode and re-encode history without the v4/v5 corpus
/// fields. A newer visible-text revision must win without erasing the only
/// retained pre-polish sample.
private static func preservingCorpusMetadata(
of winner: SpeechHistoryEntry,
fallback: SpeechHistoryEntry
) -> SpeechHistoryEntry {
let usesFallbackTranscript = winner.prePolishText == nil
&& fallback.prePolishText != nil
let prePolishText = winner.prePolishText ?? fallback.prePolishText
let polishStyleID = winner.polishStyleID ?? fallback.polishStyleID
let polishStylePromptFingerprint = winner.polishStylePromptFingerprint
?? fallback.polishStylePromptFingerprint
guard prePolishText != winner.prePolishText
|| polishStyleID != winner.polishStyleID
|| polishStylePromptFingerprint
!= winner.polishStylePromptFingerprint else {
return winner
}
return candidate.createdAt >= existing.createdAt ? candidate : existing
return SpeechHistoryEntry(
id: winner.id,
text: winner.text,
prePolishText: prePolishText,
wasTranslation: usesFallbackTranscript
? fallback.wasTranslation
: winner.wasTranslation,
polishStyleID: polishStyleID,
polishStylePromptFingerprint: polishStylePromptFingerprint,
createdAt: winner.createdAt,
modifiedAt: winner.modifiedAt,
revision: winner.revision,
engineMode: winner.engineMode,
source: winner.source
)
}
public static func polishStylePromptFingerprint(for prompt: String) -> String {
SHA256.hash(data: Data(prompt.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
mutating func prunePolishStylePromptSnapshots() {
var retained: [String: String] = [:]
var retainedCharacters = 0
let newestEntries = entries.sorted { $0.createdAt > $1.createdAt }
for entry in newestEntries {
guard retained.count < Self.maxPolishStylePromptSnapshots,
let fingerprint = entry.polishStylePromptFingerprint,
retained[fingerprint] == nil,
let prompt = polishStylePromptSnapshots[fingerprint] else {
continue
}
guard retainedCharacters + prompt.count
<= Self.maxPolishStylePromptSnapshotCharacters else {
continue
}
retained[fingerprint] = prompt
retainedCharacters += prompt.count
}
polishStylePromptSnapshots = retained
}
}
extension SyncedSpeechHistory {
mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
polishStylePromptSnapshots.removeAll()
clearedAt = date
}
}