// ClipboardSkillSemanticRanker.swift // OSGKeyboard · Shared // // Selects skills from the complete catalog for the newest accepted clipboard // entry. Analysis is local and ephemeral; installation state, user-managed // ordering, labels, and recommendations are never persisted. import Combine import Foundation public enum ClipboardSkillSemanticRanker { private static let longTextCharacterThreshold = 360 private static let languageConfidenceThreshold = 0.75 private static let maximumReplyRecommendations = 2 public static func ranked( skills: [AIClipboardSkill], sourceText: String, analysis: ClipboardSemanticAnalysis, uiLanguage _: AppUILanguage, preferredLanguages: [String] = Locale.preferredLanguages ) -> [AIClipboardSkill] { guard skills.count > 1 else { return skills } return sorted( skills, scores: relevanceScores( sourceText: sourceText, analysis: analysis, preferredLanguages: preferredLanguages ) ) } /// Returns semantically relevant skills and always keeps the generic Reply /// action available as a safe fallback for accepted clipboard text. public static func recommended( skills: [AIClipboardSkill], sourceText: String, analysis: ClipboardSemanticAnalysis, uiLanguage _: AppUILanguage, limit: Int, preferredLanguages: [String] = Locale.preferredLanguages ) -> [AIClipboardSkill] { guard limit > 0 else { return [] } var scores = relevanceScores( sourceText: sourceText, analysis: analysis, preferredLanguages: preferredLanguages ) let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID } if genericReply != nil { scores[AIClipboardSkillCatalog.replyID, default: 0] = max( 1, scores[AIClipboardSkillCatalog.replyID, default: 0] ) } let relevant = skills.filter { scores[$0.id, default: 0] > 0 } var selected: [AIClipboardSkill] = [] var specializedReplyCount = 0 for skill in sorted(relevant, scores: scores) { let mustReserveGenericReply = genericReply != nil && !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID }) && skill.id != AIClipboardSkillCatalog.replyID let availableCount = limit - (mustReserveGenericReply ? 1 : 0) guard selected.count < availableCount else { continue } if skill.id == AIClipboardSkillCatalog.replyID { selected.append(skill) continue } if skill.supportsReplyStyle { guard specializedReplyCount < maximumReplyRecommendations else { continue } specializedReplyCount += 1 } selected.append(skill) } if let genericReply, selected.count < limit, !selected.contains(where: { $0.id == genericReply.id }) { selected.append(genericReply) } return selected } private static func relevanceScores( sourceText: String, analysis: ClipboardSemanticAnalysis, preferredLanguages: [String] ) -> [String: Int] { var scores: [String: Int] = [:] func boost(_ id: String, _ value: Int) { scores[id, default: 0] += value } if let webURL = analysis.singleWebURL { boost(AIClipboardSkillCatalog.openLinkID, 320) if webURL.scheme?.lowercased() == "https" { boost(AIClipboardSkillCatalog.summarizeWebPageID, 310) } return scores } if analysis.singlePhoneNumber != nil { boost(AIClipboardSkillCatalog.callPhoneID, 320) boost(AIClipboardSkillCatalog.createContactID, 310) return scores } if isLanguageMismatch( analysis.language, preferredLanguages: preferredLanguages ) { boost(AIClipboardSkillCatalog.translateID, 230) } if analysis.hasAddress { boost(AIClipboardSkillCatalog.navigateID, 180) } if analysis.invitation.isDetected { if analysis.hasDateOrTime { boost(AIClipboardSkillCatalog.extractEventsID, 260) } boost(AIClipboardSkillCatalog.acceptInvitationID, 240) boost(AIClipboardSkillCatalog.declineInvitationID, 230) boost(AIClipboardSkillCatalog.replyID, 60) } else if analysis.hasDateOrTime { boost(AIClipboardSkillCatalog.extractEventsID, 110) } if analysis.task.isDetected { boost(AIClipboardSkillCatalog.extractTodosID, 155) boost(AIClipboardSkillCatalog.acceptTaskID, 140) boost(AIClipboardSkillCatalog.clarifyRequestID, 105) } if analysis.question.isDetected { boost(AIClipboardSkillCatalog.replyID, 145) boost(AIClipboardSkillCatalog.clarifyRequestID, 110) } // A threshold-crossing complaint can still be used as advisory evidence // if a future model loses automatic-routing approval. Ranking a chip is // reversible and remains user-initiated. if isAdvisoryComplaint(analysis.complaint) { boost(AIClipboardSkillCatalog.empathyReplyID, 105) boost(AIClipboardSkillCatalog.clarifyRequestID, 90) boost(AIClipboardSkillCatalog.replyID, 55) } else if analysis.sentiment == .negative, analysis.question.isDetected { boost(AIClipboardSkillCatalog.empathyReplyID, 85) boost(AIClipboardSkillCatalog.clarifyRequestID, 65) } if analysis.hasOrganizationName, analysis.task.isDetected || analysis.question.isDetected || analysis.invitation.isDetected { boost(AIClipboardSkillCatalog.businessReplyID, 125) } else if analysis.hasOrganizationName { boost(AIClipboardSkillCatalog.businessReplyID, 70) } if isListLike(sourceText) { boost(AIClipboardSkillCatalog.organizeListID, 145) boost(AIClipboardSkillCatalog.extractTodosID, 105) boost(AIClipboardSkillCatalog.summarizeID, 45) } if sourceText.count >= longTextCharacterThreshold { boost(AIClipboardSkillCatalog.summarizeID, 145) boost(AIClipboardSkillCatalog.saveToNotesID, 85) } let hasSpecializedReplyIntent = analysis.task.isDetected || analysis.question.isDetected || analysis.invitation.isDetected || isAdvisoryComplaint(analysis.complaint) if analysis.replyableMessage.isDetected, !hasSpecializedReplyIntent, sourceText.count < longTextCharacterThreshold, !isListLike(sourceText) { boost(AIClipboardSkillCatalog.replyID, 160) if analysis.sentiment != .negative, !isAdvisoryComplaint(analysis.complaint) { boost(AIClipboardSkillCatalog.playfulReplyID, 145) } } if analysis.sentiment == .positive { boost(AIClipboardSkillCatalog.replyID, 45) } return scores } private static func sorted( _ skills: [AIClipboardSkill], scores: [String: Int] ) -> [AIClipboardSkill] { let baseline = Dictionary( uniqueKeysWithValues: skills.enumerated().map { ($0.element.id, $0.offset) } ) return skills.sorted { lhs, rhs in let leftScore = scores[lhs.id, default: 0] let rightScore = scores[rhs.id, default: 0] if leftScore != rightScore { return leftScore > rightScore } return baseline[lhs.id, default: 0] < baseline[rhs.id, default: 0] } } private static func isLanguageMismatch( _ language: ClipboardLanguageLabel?, preferredLanguages: [String] ) -> Bool { guard let language, language.confidence >= languageConfidenceThreshold else { return false } return !SystemLanguageResolver.isSameLanguage( sourceIdentifier: language.identifier, targetIdentifier: SystemLanguageResolver.primaryIdentifier( preferredLanguages: preferredLanguages ) ) } private static func isAdvisoryComplaint(_ label: ClipboardIntentLabel) -> Bool { label.confidence > 0 && label.confidence >= label.threshold } private static func isListLike(_ text: String) -> Bool { let lines = text .split(whereSeparator: \.isNewline) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard lines.count >= 2 else { return false } let markedCount = lines.filter(isMarkedListLine).count if markedCount * 2 >= lines.count { return true } let averageLength = lines.reduce(0) { $0 + $1.count } / lines.count return lines.count >= 3 && averageLength <= 48 } private static func isMarkedListLine(_ line: String) -> Bool { if ["- ", "* ", "• ", "· "].contains(where: { line.hasPrefix($0) }) { return true } let prefix = line.prefix(while: \.isNumber) guard !prefix.isEmpty, prefix.count < line.count else { return false } let marker = line[line.index(line.startIndex, offsetBy: prefix.count)] return marker == "." || marker == "、" || marker == ")" || marker == ")" } } public struct ClipboardSemanticRankingSnapshot: Equatable, Sendable { public let entryID: UUID public let analysis: ClipboardSemanticAnalysis public init(entryID: UUID, analysis: ClipboardSemanticAnalysis) { self.entryID = entryID self.analysis = analysis } } @MainActor public final class ClipboardSemanticRankingStore: ObservableObject { public static let shared = ClipboardSemanticRankingStore() @Published public private(set) var snapshot: ClipboardSemanticRankingSnapshot? private let analyzer: ClipboardSemanticAnalyzer private var analysisTask: Task? private var generation = UUID() public init(analyzer: ClipboardSemanticAnalyzer = ClipboardSemanticAnalyzer()) { self.analyzer = analyzer } public func analyze(_ entry: ClipboardHistoryEntry) { analysisTask?.cancel() generation = UUID() let expectedGeneration = generation snapshot = nil analysisTask = Task { [weak self] in guard let self else { return } let analysis = await self.analyzer.analyze(entry.text) guard !Task.isCancelled, self.generation == expectedGeneration else { return } self.snapshot = ClipboardSemanticRankingSnapshot( entryID: entry.id, analysis: analysis ) } } public func clear() { generation = UUID() analysisTask?.cancel() analysisTask = nil snapshot = nil } }