Files
OSGKeyboard/OSGKeyboardShared/Services/ClipboardSemanticAnalyzer.swift
Rocky 2cc81c4628 feat(keyboard): expand contextual skills and managed flows
Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
2026-08-23 14:02:33 +08:00

518 lines
18 KiB
Swift

// 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 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 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<String>()
return urls.filter {
seen.insert($0.absoluteString).inserted
}
}
}
public actor ClipboardSemanticAnalyzer {
private struct Manifest: Decodable {
let schemaVersion: Int
let classifiers: [ManifestClassifier]
}
private struct ManifestClassifier: Decodable {
let id: String
let modelFile: String
let positiveLabel: String?
let confidenceThreshold: Double?
let acceptedForAutomaticRouting: Bool
}
private struct ModelEntry {
let configuration: ManifestClassifier
let model: NLModel
}
private enum IntentID: String, CaseIterable {
case task
case question
case invitation
case complaint
case replyableMessage
}
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 didAttemptManifestLoad = false
public init(additionalBundles: [Bundle] = []) {
var resolved = additionalBundles
resolved.append(Bundle(for: BundleToken.self))
resolved.append(.main)
var seen = Set<String>()
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 task = intentLabel(.task, segments: segments)
let question = intentLabel(.question, segments: segments)
let invitation = intentLabel(.invitation, segments: segments)
let complaint = intentLabel(.complaint, segments: segments)
let replyableMessage = intentLabel(.replyableMessage, segments: segments)
let sentiment = sentimentLabel(segments: segments)
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: task,
question: question,
invitation: invitation,
complaint: complaint,
replyableMessage: replyableMessage
)
}
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
)
}
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..<text.endIndex)
}
var people: [ClipboardTextLabel] = []
var organizations: [ClipboardTextLabel] = []
tagger.enumerateTags(
in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .nameType,
options: [.omitWhitespace, .omitPunctuation, .joinNames]
) { tag, range in
switch tag {
case .personalName:
people.append(ClipboardTextLabel(sourceText: String(text[range])))
case .organizationName:
organizations.append(ClipboardTextLabel(sourceText: String(text[range])))
default:
break
}
return true
}
return (deduplicated(people), deduplicated(organizations))
}
private func semanticSegments(in text: String) -> [String] {
if text.count <= Self.maximumSegmentCharacters {
return [text]
}
let tokenizer = NLTokenizer(unit: .sentence)
tokenizer.string = text
var segments: [String] = []
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
let segment = String(text[range])
.trimmingCharacters(in: .whitespacesAndNewlines)
if !segment.isEmpty {
segments.append(String(segment.prefix(Self.maximumSegmentCharacters)))
}
return segments.count < Self.maximumSemanticSegments
}
if segments.isEmpty {
return [String(text.prefix(Self.maximumSegmentCharacters))]
}
return segments
}
private func intentLabel(
_ id: IntentID,
segments: [String]
) -> 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 threshold = 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 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),
decoded.schemaVersion == 1 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<String>()
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 {}