// ClipboardSemanticAnalyzer.swift // OSGKeyboard · Shared // // Fully local clipboard labeling. Deterministic Apple detectors produce // structural facts; project-trained NLModel classifiers add conservative // sentence-level intent labels. No clipboard text leaves the device here. import Foundation import NaturalLanguage public struct ClipboardLanguageLabel: Equatable, Sendable { public let identifier: String public let confidence: Double } public struct ClipboardDateLabel: Equatable, Sendable { public let sourceText: String public let date: Date public let duration: TimeInterval public let timeZoneIdentifier: String? } public struct ClipboardTextLabel: Equatable, Sendable { public let sourceText: String } public enum ClipboardSentimentLabel: String, Equatable, Sendable { case positive case neutral case negative case unknown } public struct ClipboardIntentLabel: Equatable, Sendable { public let confidence: Double public let threshold: Double public let isDetected: Bool public let isApprovedForAutomaticRouting: Bool } public struct ClipboardVerifierDecision: Equatable, Sendable { public let group: String public let label: String public let confidence: Double public let margin: Double public let isShadow: Bool public let isRouted: Bool } public struct ClipboardSemanticAnalysis: Equatable, Sendable { public let language: ClipboardLanguageLabel? public let dates: [ClipboardDateLabel] public let addresses: [ClipboardTextLabel] public let phoneNumbers: [ClipboardTextLabel] public let urls: [URL] public let personNames: [ClipboardTextLabel] public let organizationNames: [ClipboardTextLabel] public let sentiment: ClipboardSentimentLabel public let sentimentConfidence: Double public let task: ClipboardIntentLabel public let question: ClipboardIntentLabel public let invitation: ClipboardIntentLabel public let complaint: ClipboardIntentLabel public let replyableMessage: ClipboardIntentLabel public let scheduleNegotiation: ClipboardIntentLabel public let confirmationDecision: ClipboardIntentLabel public let followUpReminder: ClipboardIntentLabel public let blessing: ClipboardIntentLabel public let actionVerifier: ClipboardVerifierDecision? public let coordinationVerifier: ClipboardVerifierDecision? public var hasDateOrTime: Bool { !dates.isEmpty } public var hasAddress: Bool { !addresses.isEmpty } public var hasPhoneNumber: Bool { !phoneNumbers.isEmpty } public var singlePhoneNumber: String? { AIPhoneNumberResolver.singlePhoneNumber(from: phoneNumbers) } public var hasURL: Bool { !urls.isEmpty } public var singleWebURL: URL? { ClipboardWebLinkResolver.singleWebURL(from: urls) } public var hasPersonName: Bool { !personNames.isEmpty } public var hasOrganizationName: Bool { !organizationNames.isEmpty } } /// Deterministic HTTP(S) extraction shared by analysis and direct URL skills. /// Bare domains are upgraded to HTTPS; explicit HTTP links preserve their scheme. public enum ClipboardWebLinkResolver: Sendable { public static func webURLs(in text: String) -> [URL] { guard let detector = try? NSDataDetector( types: NSTextCheckingResult.CheckingType.link.rawValue ) else { return [] } let range = NSRange(text.startIndex..., in: text) let urls: [URL] = detector.matches( in: text, options: [], range: range ).compactMap { match -> URL? in guard let swiftRange = Range(match.range, in: text), let url = match.url else { return nil } return normalizedWebURL( url, sourceText: String(text[swiftRange]) ) } return deduplicated(urls) } public static func singleWebURL(in text: String) -> URL? { singleWebURL(from: webURLs(in: text)) } public static func singleWebURL(from urls: [URL]) -> URL? { let webURLs = deduplicated(urls.compactMap { normalizedWebURL($0, sourceText: $0.absoluteString) }) return webURLs.count == 1 ? webURLs[0] : nil } static func normalizedWebURL(_ url: URL, sourceText: String) -> URL? { guard var components = URLComponents( url: url, resolvingAgainstBaseURL: false ) else { return nil } let source = sourceText .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() let scheme = components.scheme?.lowercased() guard scheme == "http" || scheme == "https", components.host?.isEmpty == false else { return nil } if scheme == "http", !source.hasPrefix("http://"), !source.contains("://") { components.scheme = "https" } return components.url } private static func deduplicated(_ urls: [URL]) -> [URL] { var seen = Set() return urls.filter { seen.insert($0.absoluteString).inserted } } } public actor ClipboardSemanticAnalyzer { private struct Manifest: Decodable { let schemaVersion: Int let classifiers: [ManifestClassifier] let verifiers: [ManifestVerifier]? } private struct ManifestClassifier: Decodable { let id: String let modelFile: String let positiveLabel: String? let confidenceThreshold: Double? let confidenceThresholdsByLanguage: [String: Double]? let acceptedForAutomaticRouting: Bool } private struct ManifestVerifier: Decodable { let id: String let modelFile: String let confidenceThreshold: Double let confidenceThresholdsByLanguage: [String: Double]? let minimumMargin: Double let minimumMarginsByLanguage: [String: Double]? let acceptedForAutomaticRouting: Bool let deploymentMode: String } private struct ModelEntry { let configuration: ManifestClassifier let model: NLModel } private struct VerifierEntry { let configuration: ManifestVerifier let model: NLModel } private enum IntentID: String, CaseIterable { case task case question case invitation case complaint case replyableMessage case scheduleNegotiation case confirmationDecision case followUpReminder case blessing } private static let resourceDirectory = "ClipboardSemantics" private static let manifestName = "clipboard-semantic-models" private static let maximumSemanticSegments = 8 private static let maximumSegmentCharacters = 500 private static let minimumSentimentConfidence = 0.65 private static let minimumSentimentMargin = 0.15 private let bundles: [Bundle] private var manifest: Manifest? private var models: [String: ModelEntry] = [:] private var verifierModels: [String: VerifierEntry] = [:] private var didAttemptManifestLoad = false public init(additionalBundles: [Bundle] = []) { var resolved = additionalBundles resolved.append(Bundle(for: BundleToken.self)) resolved.append(.main) var seen = Set() bundles = resolved.filter { seen.insert($0.bundlePath).inserted } } public func analyze(_ sourceText: String) -> ClipboardSemanticAnalysis { let text = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return emptyAnalysis() } let language = languageLabel(for: text) let detectedData = detectStructuredData(in: text) let entities = detectNames( in: text, language: language.flatMap { NLLanguage(rawValue: $0.identifier) } ) let segments = semanticSegments(in: text) let languageIdentifier = language?.identifier let taskCandidate = intentLabel( .task, segments: segments, languageIdentifier: languageIdentifier ) let questionCandidate = intentLabel( .question, segments: segments, languageIdentifier: languageIdentifier ) let invitationCandidate = intentLabel( .invitation, segments: segments, languageIdentifier: languageIdentifier ) let complaint = intentLabel( .complaint, segments: segments, languageIdentifier: languageIdentifier ) let task = adjustedTaskLabel( taskCandidate, complaint: complaint, text: text ) let replyableMessage = intentLabel( .replyableMessage, segments: segments, languageIdentifier: languageIdentifier ) let scheduleNegotiationCandidate = intentLabel( .scheduleNegotiation, segments: segments, languageIdentifier: languageIdentifier ) let confirmationDecisionCandidate = intentLabel( .confirmationDecision, segments: segments, languageIdentifier: languageIdentifier ) let followUpReminderCandidate = intentLabel( .followUpReminder, segments: segments, languageIdentifier: languageIdentifier ) let blessingCandidate = intentLabel( .blessing, segments: segments, languageIdentifier: languageIdentifier ) let blessing = adjustedBlessingLabel(blessingCandidate, text: text) let sentiment = sentimentLabel(segments: segments) let actionVerifier = verifierDecision( id: "action", segments: segments, languageIdentifier: languageIdentifier, shouldEvaluate: shouldEvaluateActionVerifier( task: task, question: questionCandidate, complaint: complaint ) ) let coordinationVerifier = verifierDecision( id: "coordination", segments: segments, languageIdentifier: languageIdentifier, shouldEvaluate: shouldEvaluateCoordinationVerifier( invitation: invitationCandidate, scheduleNegotiation: scheduleNegotiationCandidate, confirmationDecision: confirmationDecisionCandidate, followUpReminder: followUpReminderCandidate ) ) let verifiedAction = verifiedActionLabels( task: task, question: questionCandidate, complaint: complaint, decision: actionVerifier ) let verifiedCoordination = verifiedCoordinationLabels( invitation: invitationCandidate, scheduleNegotiation: scheduleNegotiationCandidate, confirmationDecision: confirmationDecisionCandidate, followUpReminder: followUpReminderCandidate, decision: coordinationVerifier ) return ClipboardSemanticAnalysis( language: language, dates: detectedData.dates, addresses: detectedData.addresses, phoneNumbers: detectedData.phoneNumbers, urls: detectedData.urls, personNames: entities.people, organizationNames: entities.organizations, sentiment: sentiment.label, sentimentConfidence: sentiment.confidence, task: verifiedAction.task, question: verifiedAction.question, invitation: verifiedCoordination.invitation, complaint: verifiedAction.complaint, replyableMessage: replyableMessage, scheduleNegotiation: verifiedCoordination.scheduleNegotiation, confirmationDecision: verifiedCoordination.confirmationDecision, followUpReminder: verifiedCoordination.followUpReminder, blessing: blessing, actionVerifier: actionVerifier, coordinationVerifier: coordinationVerifier ) } private func shouldEvaluateActionVerifier( task: ClipboardIntentLabel, question: ClipboardIntentLabel, complaint: ClipboardIntentLabel ) -> Bool { [task, question, complaint].contains { $0.confidence >= min($0.threshold, 0.50) } } private func shouldEvaluateCoordinationVerifier( invitation: ClipboardIntentLabel, scheduleNegotiation: ClipboardIntentLabel, confirmationDecision: ClipboardIntentLabel, followUpReminder: ClipboardIntentLabel ) -> Bool { [ invitation, scheduleNegotiation, confirmationDecision, followUpReminder ].contains { $0.confidence >= min($0.threshold, 0.50) } } private func verifiedActionLabels( task: ClipboardIntentLabel, question: ClipboardIntentLabel, complaint: ClipboardIntentLabel, decision: ClipboardVerifierDecision? ) -> ( task: ClipboardIntentLabel, question: ClipboardIntentLabel, complaint: ClipboardIntentLabel ) { guard let configuration = verifierConfiguration(id: "action") else { return (task, question, complaint) } guard configuration.acceptedForAutomaticRouting, configuration.deploymentMode == "automatic" else { return (task, question, complaint) } guard let decision else { return ( routedLabel(task, isApproved: false), routedLabel(question, isApproved: false), routedLabel(complaint, isApproved: false) ) } let taskApproved = decision.isRouted && ["taskOnly", "both"].contains(decision.label) let questionApproved = decision.isRouted && decision.label == "questionRequest" let complaintApproved = decision.isRouted && ["complaintOnly", "both"].contains(decision.label) return ( routedLabel(task, isApproved: taskApproved), routedLabel(question, isApproved: questionApproved), routedLabel(complaint, isApproved: complaintApproved) ) } private func verifiedCoordinationLabels( invitation: ClipboardIntentLabel, scheduleNegotiation: ClipboardIntentLabel, confirmationDecision: ClipboardIntentLabel, followUpReminder: ClipboardIntentLabel, decision: ClipboardVerifierDecision? ) -> ( invitation: ClipboardIntentLabel, scheduleNegotiation: ClipboardIntentLabel, confirmationDecision: ClipboardIntentLabel, followUpReminder: ClipboardIntentLabel ) { guard let configuration = verifierConfiguration(id: "coordination") else { return ( invitation, scheduleNegotiation, confirmationDecision, followUpReminder ) } guard configuration.acceptedForAutomaticRouting, configuration.deploymentMode == "automatic" else { return ( invitation, scheduleNegotiation, confirmationDecision, followUpReminder ) } guard let decision else { return ( routedLabel(invitation, isApproved: false), routedLabel(scheduleNegotiation, isApproved: false), routedLabel(confirmationDecision, isApproved: false), routedLabel(followUpReminder, isApproved: false) ) } return ( routedLabel( invitation, isApproved: decision.isRouted && decision.label == "invitation" ), routedLabel( scheduleNegotiation, isApproved: decision.isRouted && decision.label == "scheduleNegotiation" ), routedLabel( confirmationDecision, isApproved: decision.isRouted && decision.label == "confirmationDecision" ), routedLabel( followUpReminder, isApproved: decision.isRouted && decision.label == "followUpReminder" ) ) } private func routedLabel( _ candidate: ClipboardIntentLabel, isApproved: Bool ) -> ClipboardIntentLabel { ClipboardIntentLabel( confidence: candidate.confidence, threshold: candidate.threshold, isDetected: isApproved, isApprovedForAutomaticRouting: isApproved ) } private func adjustedTaskLabel( _ task: ClipboardIntentLabel, complaint: ClipboardIntentLabel, text: String ) -> ClipboardIntentLabel { guard task.isDetected, Self.shouldSuppressTask( text: text, complaintConfidence: complaint.confidence ) else { return task } return ClipboardIntentLabel( confidence: task.confidence, threshold: task.threshold, isDetected: false, isApprovedForAutomaticRouting: task.isApprovedForAutomaticRouting ) } static func shouldSuppressTask( text: String, complaintConfidence: Double ) -> Bool { guard complaintConfidence >= 0.60 else { return false } let normalized = text.lowercased() let explicitTaskMarkers = [ "请", "麻烦", "能否", "可以请你", "由你", "交给你", "需要你", "你负责", "下一步", "行动项", "please ", "can you", "could you", "would you", "assigned to you", "you are responsible", "we need you", "would like you", "counting on you", "take ownership", "your task", "next action", "complete the", "finish the", "send it to", "deliver it to" ] return !explicitTaskMarkers.contains { normalized.contains($0) } } private func adjustedBlessingLabel( _ candidate: ClipboardIntentLabel, text: String ) -> ClipboardIntentLabel { guard Self.hasExplicitBlessingMarker(in: text) else { return ClipboardIntentLabel( confidence: candidate.confidence, threshold: candidate.threshold, isDetected: false, isApprovedForAutomaticRouting: candidate.isApprovedForAutomaticRouting ) } // Explicit blessing phrases are deterministic routing evidence. The // statistical model remains useful for diagnostics, but cannot route // broad positive language without one of these high-precision markers. return ClipboardIntentLabel( confidence: 1, threshold: 1, isDetected: true, isApprovedForAutomaticRouting: true ) } static func hasExplicitBlessingMarker(in text: String) -> Bool { let normalized = text.lowercased() let quotedOrMetaContexts = [ "祝福模板", "祝福语模板", "文章引用", "搜索词", "系统正在检查", "文档里收录", "贺卡名单", "收集祝福", "greeting template", "message template", "the article quotes", "search phrase", "system is checking", "document contains", "card list", "quotes the phrase", "如何描述生日快乐", "怎么说生日快乐", "如何写生日祝福", "how would you describe a happy birthday", "how do you say happy birthday", "what does happy birthday mean", "宁愿你", "祝你倒闭", "祝你立马倒闭", "祝你去死", "祝你倒霉", "祝你失败", "祝你完蛋" ] guard !quotedOrMetaContexts.contains(where: { normalized.contains($0) }) else { return false } let markers = [ "生日快乐", "新年快乐", "春节快乐", "节日快乐", "圣诞快乐", "中秋快乐", "恭喜", "预祝", "祝你", "祝您", "祝大家", "祝他", "祝她", "愿你", "愿您", "happy birthday", "happy new year", "merry christmas", "happy holidays", "congratulations", "congrats", "best wishes", "good luck", "wishing you", "wish you", "wish him", "wish her", "wish them", "let us wish", "let's wish", "we wish", "may you" ] return markers.contains { normalized.contains($0) } } private func emptyAnalysis() -> ClipboardSemanticAnalysis { let emptyIntent = ClipboardIntentLabel( confidence: 0, threshold: 1, isDetected: false, isApprovedForAutomaticRouting: false ) return ClipboardSemanticAnalysis( language: nil, dates: [], addresses: [], phoneNumbers: [], urls: [], personNames: [], organizationNames: [], sentiment: .unknown, sentimentConfidence: 0, task: emptyIntent, question: emptyIntent, invitation: emptyIntent, complaint: emptyIntent, replyableMessage: emptyIntent, scheduleNegotiation: emptyIntent, confirmationDecision: emptyIntent, followUpReminder: emptyIntent, blessing: emptyIntent, actionVerifier: nil, coordinationVerifier: nil ) } private func languageLabel(for text: String) -> ClipboardLanguageLabel? { let recognizer = NLLanguageRecognizer() recognizer.processString(text) guard let dominant = recognizer.dominantLanguage else { return nil } let confidence = recognizer.languageHypotheses(withMaximum: 3)[dominant] ?? 0 return ClipboardLanguageLabel( identifier: dominant.rawValue, confidence: rounded(confidence) ) } private func detectStructuredData( in text: String ) -> ( dates: [ClipboardDateLabel], addresses: [ClipboardTextLabel], phoneNumbers: [ClipboardTextLabel], urls: [URL] ) { let checkingTypes: NSTextCheckingResult.CheckingType = [ .date, .address, .phoneNumber, .link ] guard let detector = try? NSDataDetector(types: checkingTypes.rawValue) else { return ([], [], [], []) } let range = NSRange(text.startIndex..., in: text) var dates: [ClipboardDateLabel] = [] var addresses: [ClipboardTextLabel] = [] var phoneNumbers: [ClipboardTextLabel] = [] var urls: [URL] = [] for match in detector.matches(in: text, options: [], range: range) { guard let swiftRange = Range(match.range, in: text) else { continue } let source = String(text[swiftRange]) switch match.resultType { case .date: if let date = match.date { dates.append( ClipboardDateLabel( sourceText: source, date: date, duration: match.duration, timeZoneIdentifier: match.timeZone?.identifier ) ) } case .address: addresses.append(ClipboardTextLabel(sourceText: source)) case .phoneNumber: phoneNumbers.append( ClipboardTextLabel(sourceText: match.phoneNumber ?? source) ) case .link: if let url = match.url, let url = ClipboardWebLinkResolver.normalizedWebURL( url, sourceText: source ) { urls.append(url) } default: continue } } return ( dates, deduplicated(addresses), deduplicated(phoneNumbers), Array(Set(urls)).sorted { $0.absoluteString < $1.absoluteString } ) } private func detectNames( in text: String, language: NLLanguage? ) -> ( people: [ClipboardTextLabel], organizations: [ClipboardTextLabel] ) { let tagger = NLTagger(tagSchemes: [.nameType]) tagger.string = text if let language { tagger.setLanguage(language, range: text.startIndex.. [String] { if text.count <= Self.maximumSegmentCharacters { return [text] } let tokenizer = NLTokenizer(unit: .sentence) tokenizer.string = text var segments: [String] = [] tokenizer.enumerateTokens(in: text.startIndex.. ClipboardIntentLabel { guard let entry = modelEntry(id: id.rawValue), let positiveLabel = entry.configuration.positiveLabel else { return ClipboardIntentLabel( confidence: 0, threshold: 1, isDetected: false, isApprovedForAutomaticRouting: false ) } let languageThreshold = languageIdentifier.flatMap { entry.configuration.confidenceThresholdsByLanguage?[$0] } let threshold = languageThreshold ?? entry.configuration.confidenceThreshold ?? 1 let confidence = segments.map { segment in entry.model.predictedLabelHypotheses( for: segment, maximumCount: 2 )[positiveLabel] ?? 0 }.max() ?? 0 let approved = entry.configuration.acceptedForAutomaticRouting return ClipboardIntentLabel( confidence: rounded(confidence), threshold: rounded(threshold), isDetected: approved && confidence >= threshold, isApprovedForAutomaticRouting: approved ) } private func sentimentLabel( segments: [String] ) -> (label: ClipboardSentimentLabel, confidence: Double) { guard let entry = modelEntry(id: "sentiment") else { return (.unknown, 0) } var totals: [String: Double] = [:] for segment in segments { for (label, confidence) in entry.model.predictedLabelHypotheses( for: segment, maximumCount: 3 ) { totals[label, default: 0] += confidence } } let divisor = Double(max(segments.count, 1)) let ranked = totals .map { (label: $0.key, confidence: $0.value / divisor) } .sorted { $0.confidence > $1.confidence } guard let winner = ranked.first else { return (.unknown, 0) } let runnerUp = ranked.dropFirst().first?.confidence ?? 0 guard entry.configuration.acceptedForAutomaticRouting, winner.confidence >= Self.minimumSentimentConfidence, winner.confidence - runnerUp >= Self.minimumSentimentMargin, let label = ClipboardSentimentLabel(rawValue: winner.label) else { return (.unknown, rounded(winner.confidence)) } return (label, rounded(winner.confidence)) } private func modelEntry(id: String) -> ModelEntry? { if let cached = models[id] { return cached } guard let configuration = loadedManifest()? .classifiers .first(where: { $0.id == id }), let modelURL = modelURL(fileName: configuration.modelFile), let model = try? NLModel(contentsOf: modelURL) else { return nil } let entry = ModelEntry(configuration: configuration, model: model) models[id] = entry return entry } private func verifierDecision( id: String, segments: [String], languageIdentifier: String?, shouldEvaluate: Bool ) -> ClipboardVerifierDecision? { guard shouldEvaluate, let entry = verifierEntry(id: id) else { return nil } let rankedSegments = segments.compactMap { segment -> ( label: String, confidence: Double, margin: Double )? in let ranked = entry.model.predictedLabelHypotheses( for: segment, maximumCount: 2 ).sorted { $0.value > $1.value } guard let winner = ranked.first else { return nil } return ( winner.key, winner.value, winner.value - (ranked.dropFirst().first?.value ?? 0) ) } guard let winner = rankedSegments.max(by: { if $0.confidence != $1.confidence { return $0.confidence < $1.confidence } return $0.margin < $1.margin }) else { return nil } let threshold = languageIdentifier.flatMap { entry.configuration.confidenceThresholdsByLanguage?[$0] } ?? entry.configuration.confidenceThreshold let minimumMargin = languageIdentifier.flatMap { entry.configuration.minimumMarginsByLanguage?[$0] } ?? entry.configuration.minimumMargin let isShadow = entry.configuration.deploymentMode == "shadow" || !entry.configuration.acceptedForAutomaticRouting let isRouted = winner.label != "neither" && winner.confidence >= threshold && winner.margin >= minimumMargin return ClipboardVerifierDecision( group: id, label: winner.label, confidence: rounded(winner.confidence), margin: rounded(winner.margin), isShadow: isShadow, isRouted: isRouted ) } private func verifierEntry(id: String) -> VerifierEntry? { if let cached = verifierModels[id] { return cached } guard let configuration = loadedManifest()? .verifiers? .first(where: { $0.id == id }), let modelURL = modelURL(fileName: configuration.modelFile), let model = try? NLModel(contentsOf: modelURL) else { return nil } let entry = VerifierEntry(configuration: configuration, model: model) verifierModels[id] = entry return entry } private func verifierConfiguration(id: String) -> ManifestVerifier? { loadedManifest()?.verifiers?.first { $0.id == id } } private func loadedManifest() -> Manifest? { if didAttemptManifestLoad { return manifest } didAttemptManifestLoad = true let decoder = JSONDecoder() for bundle in bundles { let url = bundle.url( forResource: Self.manifestName, withExtension: "json", subdirectory: Self.resourceDirectory ) ?? bundle.url( forResource: Self.manifestName, withExtension: "json" ) guard let url, let data = try? Data(contentsOf: url), let decoded = try? decoder.decode(Manifest.self, from: data), (1...3).contains(decoded.schemaVersion) else { continue } manifest = decoded return decoded } return nil } private func modelURL(fileName: String) -> URL? { let sourceURL = URL(fileURLWithPath: fileName) let resource = sourceURL.deletingPathExtension().lastPathComponent for bundle in bundles { if let url = bundle.url( forResource: resource, withExtension: "mlmodelc", subdirectory: Self.resourceDirectory ) ?? bundle.url( forResource: resource, withExtension: "mlmodelc" ) { return url } } return nil } private func deduplicated( _ labels: [ClipboardTextLabel] ) -> [ClipboardTextLabel] { var seen = Set() return labels.filter { seen.insert($0.sourceText.folding( options: [.caseInsensitive, .diacriticInsensitive], locale: .current )).inserted } } private func rounded(_ value: Double) -> Double { (value * 10_000).rounded() / 10_000 } } private final class BundleToken {}