// ClipboardReplyFeedbackStore.swift // OSGKeyboard · Shared // // Keeps a bounded, device-local record of explicit clipboard-reply choices. // These model-generated candidates are comparative preference evidence, not // user-authored corpus. The store is intentionally excluded from iCloud sync. import Foundation public struct ClipboardReplyCandidateSnapshot: Codable, Equatable, Identifiable, Sendable { public enum Kind: String, Codable, CaseIterable, Sendable { case ordinary case formal case playful case invitationAccept case invitationDecline case invitationTentative case taskAcknowledge case taskClarify case taskNegotiate case blessingReturn case blessingWarm case blessingPlayful case clarificationDirect case clarificationQuestion case clarificationConfirm case answerAffirmative case answerNegative case answerConditional case answerDefer } public let id: UUID public let kind: Kind public let text: String public let emotion: String public init( id: UUID = UUID(), kind: Kind, text: String, emotion: String ) { self.id = id self.kind = kind self.text = text self.emotion = emotion } } public struct ClipboardReplyFeedbackRecord: Codable, Equatable, Identifiable, Sendable { public enum Outcome: String, Codable, Sendable { case awaitingSelection case selected case discarded } public let id: UUID public let createdAt: Date public let sourceText: String public let candidates: [ClipboardReplyCandidateSnapshot] public let styleID: String? public var selectedCandidateID: UUID? public var answerID: UUID? public var outcome: Outcome public var finalText: String? public var finalRevision: Int64? public init( id: UUID = UUID(), createdAt: Date = Date(), sourceText: String, candidates: [ClipboardReplyCandidateSnapshot], styleID: String?, selectedCandidateID: UUID? = nil, answerID: UUID? = nil, outcome: Outcome = .awaitingSelection, finalText: String? = nil, finalRevision: Int64? = nil ) { self.id = id self.createdAt = createdAt self.sourceText = sourceText self.candidates = candidates self.styleID = styleID self.selectedCandidateID = selectedCandidateID self.answerID = answerID self.outcome = outcome self.finalText = finalText self.finalRevision = finalRevision } public var selectedCandidate: ClipboardReplyCandidateSnapshot? { guard let selectedCandidateID else { return nil } return candidates.first { $0.id == selectedCandidateID } } } @MainActor public final class ClipboardReplyFeedbackStore { public static let shared = ClipboardReplyFeedbackStore() public static let maximumRecords = 100 public static let retentionInterval: TimeInterval = 30 * 24 * 60 * 60 public static let maximumSourceCharacters = 4_000 public static let maximumCandidateCharacters = 1_500 public static let maximumPayloadBytes = 512 * 1_024 private static let storageKey = "clipboard.replyFeedback.v1" private let defaults: UserDefaults? public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) { self.defaults = defaults } public func records(now: Date = Date()) -> [ClipboardReplyFeedbackRecord] { guard let defaults, let data = defaults.data(forKey: Self.storageKey), let decoded = try? JSONDecoder().decode( [ClipboardReplyFeedbackRecord].self, from: data ) else { return [] } let result = sanitized(decoded, now: now) if result != decoded { persist(result, now: now) } return result } /// One resolved exchange in a recent chat: their message and the reply the /// user actually chose (edited text preferred over the raw candidate). public struct ConversationTurn: Sendable, Equatable { public let incoming: String public let reply: String public init(incoming: String, reply: String) { self.incoming = incoming self.reply = reply } } /// Recent resolved exchanges, oldest → newest, for use as background context /// on the next reply. This is a time-window approximation of "the same /// conversation" — iOS gives a keyboard no source-app identity, so proximity /// in time is the only signal available. Only `.selected` records with a /// known reply are returned; awaiting/discarded rows carry no exchange. /// `excludingIncoming` drops the message currently being replied to so it is /// never duplicated as both context and the live prompt. public func recentConversationTurns( now: Date = Date(), window: TimeInterval = 5 * 60, maximumTurns: Int = 6, excludingIncoming: String? = nil ) -> [ConversationTurn] { let cutoff = now.addingTimeInterval(-window) let excluded = excludingIncoming?.trimmingCharacters(in: .whitespacesAndNewlines) let turns: [ConversationTurn] = records(now: now) .filter { $0.createdAt >= cutoff && $0.outcome == .selected } .sorted { $0.createdAt < $1.createdAt } .compactMap { record in let reply = (record.finalText ?? record.selectedCandidate?.text)? .trimmingCharacters(in: .whitespacesAndNewlines) guard let reply, !reply.isEmpty else { return nil } let incoming = record.sourceText.trimmingCharacters(in: .whitespacesAndNewlines) guard !incoming.isEmpty, incoming != excluded else { return nil } return ConversationTurn(incoming: incoming, reply: reply) } return Array(turns.suffix(maximumTurns)) } /// Converts local records into the bounded comparative schema accepted by /// Personal Style V2. Awaiting rows are excluded because they contain no /// user decision. public func learningExamples( now: Date = Date() ) -> [PolishStyleReplyLearningExample] { records(now: now).compactMap { record in guard record.outcome != .awaitingSelection else { return nil } guard let ordinary = record.candidates.first(where: { $0.kind == .ordinary }) else { // Scene-specific choices express the user's decision, not a // reusable tone preference. Only a later user-authored edit is // valid personal-style evidence. guard record.outcome == .selected, let selected = record.selectedCandidate, let finalText = record.finalText else { return nil } return PolishStyleReplyLearningExample( receivedMessage: record.sourceText, ordinaryCandidate: selected.text, selection: .contextual, finalEdit: finalText, createdAt: record.createdAt, styleID: record.styleID ) } let selection: PolishStyleReplySelection if record.outcome == .discarded { selection = .discarded } else { guard let selected = record.selectedCandidate else { return nil } selection = learningSelection(for: selected.kind) } return PolishStyleReplyLearningExample( receivedMessage: record.sourceText, ordinaryCandidate: ordinary.text, formalCandidate: record.candidates.first(where: { $0.kind == .formal })?.text, playfulCandidate: record.candidates.first(where: { $0.kind == .playful })?.text, selection: selection, finalEdit: record.finalText, createdAt: record.createdAt, styleID: record.styleID ) } } /// Starts a comparative feedback record after reply candidates have been /// generated. Returns nil when the source or any candidate is sensitive. @discardableResult public func begin( sourceText: String, candidates: [ClipboardReplyCandidateSnapshot], styleID: String?, now: Date = Date() ) -> UUID? { guard let source = acceptedBoundedText( sourceText, maximumCharacters: Self.maximumSourceCharacters ) else { return nil } let kinds = Set(candidates.map(\.kind)) guard !candidates.isEmpty, candidates.count == kinds.count else { return nil } let sanitizedCandidates = candidates.compactMap { candidate -> ClipboardReplyCandidateSnapshot? in guard let text = acceptedBoundedText( candidate.text, maximumCharacters: Self.maximumCandidateCharacters ) else { return nil } return ClipboardReplyCandidateSnapshot( id: candidate.id, kind: candidate.kind, text: text, emotion: String(candidate.emotion.prefix(48)) ) } guard sanitizedCandidates.count == candidates.count else { return nil } let record = ClipboardReplyFeedbackRecord( createdAt: now, sourceText: source, candidates: sanitizedCandidates, styleID: normalizedStyleID(styleID) ) var next = records(now: now) next.insert(record, at: 0) persist(next, now: now) return record.id } public func recordSelection( recordID: UUID, candidateID: UUID, answerID: UUID ) { var next = records() guard let index = next.firstIndex(where: { $0.id == recordID }), next[index].candidates.contains(where: { $0.id == candidateID }) else { return } next[index].selectedCandidateID = candidateID next[index].answerID = answerID next[index].outcome = .selected persist(next) } public func recordDiscard(recordID: UUID) { var next = records() guard let index = next.firstIndex(where: { $0.id == recordID }), next[index].outcome == .awaitingSelection else { return } next[index].outcome = .discarded persist(next) } /// Records only edits that OSGKeyboard can associate with the exact AI /// answer ID. Arbitrary host-app edits are intentionally not inferred. public func recordFinalEdit( answerID: UUID, text: String, revision: Int64 ) { guard revision > 0, let finalText = acceptedBoundedText( text, maximumCharacters: Self.maximumCandidateCharacters ) else { return } var next = records() guard let index = next.firstIndex(where: { $0.answerID == answerID }), next[index].outcome == .selected else { return } next[index].finalText = finalText next[index].finalRevision = revision persist(next) } public func clear() { defaults?.removeObject(forKey: Self.storageKey) } private func acceptedBoundedText( _ raw: String, maximumCharacters: Int ) -> String? { guard let accepted = ClipboardHistoryPolicy.acceptedText(from: raw) else { return nil } return String(accepted.prefix(maximumCharacters)) } private func normalizedStyleID(_ raw: String?) -> String? { let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return value.isEmpty ? nil : String(value.prefix(128)) } private func learningSelection( for kind: ClipboardReplyCandidateSnapshot.Kind ) -> PolishStyleReplySelection { switch kind { case .ordinary: return .ordinary case .formal: return .formal case .playful: return .playful case .invitationAccept, .invitationDecline, .invitationTentative, .taskAcknowledge, .taskClarify, .taskNegotiate, .blessingReturn, .blessingWarm, .blessingPlayful, .clarificationDirect, .clarificationQuestion, .clarificationConfirm, .answerAffirmative, .answerNegative, .answerConditional, .answerDefer: return .contextual } } private func sanitized( _ records: [ClipboardReplyFeedbackRecord], now: Date ) -> [ClipboardReplyFeedbackRecord] { let cutoff = now.addingTimeInterval(-Self.retentionInterval) return Array( records .filter { $0.createdAt >= cutoff && $0.createdAt <= now } .sorted { $0.createdAt > $1.createdAt } .prefix(Self.maximumRecords) ) } private func persist( _ records: [ClipboardReplyFeedbackRecord], now: Date = Date() ) { guard let defaults else { return } var next = sanitized(records, now: now) var encoded = try? JSONEncoder().encode(next) while (encoded?.count ?? 0) > Self.maximumPayloadBytes, !next.isEmpty { next.removeLast() encoded = try? JSONEncoder().encode(next) } guard !next.isEmpty, let encoded else { defaults.removeObject(forKey: Self.storageKey) return } defaults.set(encoded, forKey: Self.storageKey) } }