953 lines
34 KiB
Swift
953 lines
34 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 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<String>()
|
|
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<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 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..<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],
|
|
languageIdentifier: 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 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<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 {}
|