Files
OSGKeyboard/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift
T
rockyandClaude Opus 5 628e983794 merge: bring local uncommitted work onto the cloud-agent branch
Merges the wip/local-snapshot-20260901 safety commit (94 uncommitted files:
in-flight Account/Mac/Tip work, clipboard-semantics improvements, and the
feature-preview recording tooling) into the newer cloud-agent line.

Conflict resolutions:
- docs/*: honour the branch's deliberate removal in eac44da; drop the now
  dangling docs/archive/README.md that only pointed at the deleted files.
- CHANGELOG: both sides describe separate [Unreleased] features; keep both.
- ClipboardSkillSemanticRanker: take the branch's reply routing, which retires
  ranking playfulReply/acceptInvitation/... as separate skills in favour of
  ordinary/formal/playful variants inside one reply action. Keep the local
  link/phone/CJK-density work, which is orthogonal and still referenced.
- OfficialSkillCatalogTests: keep the local "installed exactly once" assertion;
  it matches the test's own comment and does not hardcode the default set.
- ClipboardSkillSemanticRankerTests: keep both sides' disjoint test functions;
  retarget the two link/phone cases at the branch's row, which no longer
  surfaces the retired specialised reply skills.

Verified: xcodebuild build succeeds and the 77 ranker/catalog/analyzer tests
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:34:23 -07:00

511 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
/// Calibrated against Latin script. `effectiveLength` scales dense scripts
/// up to the same information density before comparing.
private static let longTextLengthThreshold = 360.0
/// Branch-side raw character gate. Kept alongside the density-aware
/// `longTextLengthThreshold` because the two are used by different
/// call sites: intent routing counts characters, while the summary and
/// list heuristics weight CJK density via `effectiveLength`.
private static let longTextCharacterThreshold = 360
private static let listLineLengthThreshold = 48.0
private static let denseScriptCharacterWeight = 2.25
/// A bare link or phone paste keeps at most a short label ("详情:",
/// "Contact:"). Anything longer means the entity is embedded in a real
/// message, which must keep its own skills.
private static let entityResidualLengthLimit = 12
private static let languageConfidenceThreshold = 0.75
/// Characters permitted in a URL, used to carve the link out of text with no
/// whitespace around it (`详见https://example.com`). Deliberately spelled out
/// in ASCII: `CharacterSet.alphanumerics` also matches CJK ideographs, which
/// would swallow the surrounding message.
private static let urlCharacters = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789"
+ "-._~:/?#[]@!$&'()*+,;=%"
)
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 keeps generic Reply as a safe
/// fallback unless a display-only boundary intent suppresses human routing.
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 = suppressesInterpersonalRouting(analysis)
? nil
: 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] = []
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
}
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
}
// A bare link or phone paste has no other intent to serve, so it stays
// exclusive. One embedded in a message must not suppress the skills the
// message itself earns — scoring continues at a weight that still ranks
// the direct action high without outranking a strong message intent.
if let webURL = analysis.singleWebURL {
let isBarePaste = isWebLinkDominant(sourceText, url: webURL)
boost(AIClipboardSkillCatalog.openLinkID, isBarePaste ? 320 : 200)
if webURL.scheme?.lowercased() == "https" {
boost(AIClipboardSkillCatalog.summarizeWebPageID, isBarePaste ? 310 : 150)
}
if isBarePaste {
return scores
}
}
if analysis.singlePhoneNumber != nil {
let isBarePaste = isPhoneNumberDominant(sourceText)
boost(AIClipboardSkillCatalog.callPhoneID, isBarePaste ? 320 : 200)
boost(AIClipboardSkillCatalog.createContactID, isBarePaste ? 310 : 150)
if isBarePaste {
return scores
}
}
if isLanguageMismatch(
analysis.language,
preferredLanguages: preferredLanguages
) {
boost(AIClipboardSkillCatalog.translateID, 230)
}
if analysis.hasAddress {
boost(AIClipboardSkillCatalog.navigateID, 180)
}
let suppressesInterpersonalRouting = suppressesInterpersonalRouting(analysis)
if !suppressesInterpersonalRouting {
if isRoutingEvidence(analysis.invitation) {
if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 260)
}
boost(AIClipboardSkillCatalog.replyID, 300)
} else if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 110)
}
} else if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 110)
}
if !suppressesInterpersonalRouting {
// A threshold-crossing, evaluation-gated model may still rank a
// reversible chip; execution always remains explicitly user-initiated.
if isRoutingEvidence(analysis.scheduleNegotiation) {
boost(AIClipboardSkillCatalog.extractEventsID, 200)
boost(AIClipboardSkillCatalog.replyID, 300)
}
if isRoutingEvidence(analysis.confirmationDecision) {
boost(AIClipboardSkillCatalog.replyID, 300)
}
if isRoutingEvidence(analysis.followUpReminder) {
boost(AIClipboardSkillCatalog.extractTodosID, 285)
boost(AIClipboardSkillCatalog.replyID, 250)
}
if isRoutingEvidence(analysis.task) {
boost(AIClipboardSkillCatalog.extractTodosID, 155)
boost(AIClipboardSkillCatalog.replyID, 140)
}
if isRoutingEvidence(analysis.question) {
boost(AIClipboardSkillCatalog.replyID, 145)
}
if isRoutingEvidence(analysis.blessing) {
boost(AIClipboardSkillCatalog.replyID, 300)
}
if isRoutingEvidence(analysis.complaint) {
boost(AIClipboardSkillCatalog.replyID, 105)
} else if analysis.sentiment == .negative,
isRoutingEvidence(analysis.question) {
boost(AIClipboardSkillCatalog.replyID, 85)
}
if analysis.hasOrganizationName,
isRoutingEvidence(analysis.task)
|| isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.invitation) {
boost(AIClipboardSkillCatalog.replyID, 125)
} else if analysis.hasOrganizationName {
boost(AIClipboardSkillCatalog.replyID, 70)
}
}
if isListLike(sourceText) {
boost(AIClipboardSkillCatalog.organizeListID, 145)
boost(AIClipboardSkillCatalog.extractTodosID, 105)
boost(AIClipboardSkillCatalog.summarizeID, 45)
}
if effectiveLength(sourceText) >= longTextLengthThreshold {
boost(AIClipboardSkillCatalog.summarizeID, 145)
boost(AIClipboardSkillCatalog.saveToNotesID, 85)
}
if !suppressesInterpersonalRouting {
let hasSpecializedReplyIntent = isRoutingEvidence(analysis.task)
|| isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.invitation)
|| isRoutingEvidence(analysis.scheduleNegotiation)
|| isRoutingEvidence(analysis.confirmationDecision)
|| isRoutingEvidence(analysis.followUpReminder)
|| isRoutingEvidence(analysis.blessing)
|| isRoutingEvidence(analysis.complaint)
if analysis.replyableMessage.isDetected,
!hasSpecializedReplyIntent,
sourceText.count < longTextCharacterThreshold,
!isListLike(sourceText) {
boost(AIClipboardSkillCatalog.replyID, 160)
if analysis.sentiment != .negative,
!isRoutingEvidence(analysis.complaint) {
// Reply now exposes ordinary, formal, and playful variants
// inside one action rather than ranking separate style skills.
boost(AIClipboardSkillCatalog.replyID, 145)
}
}
if analysis.sentiment == .positive {
boost(AIClipboardSkillCatalog.replyID, 45)
}
}
applyDomainBoosts(
analysis,
sourceText: sourceText,
suppressesInterpersonalRouting: suppressesInterpersonalRouting,
boost: boost
)
return scores
}
private static func applyDomainBoosts(
_ analysis: ClipboardSemanticAnalysis,
sourceText: String,
suppressesInterpersonalRouting: Bool,
boost: (String, Int) -> Void
) {
guard let domain = analysis.domain,
let confidence = analysis.domainConfidence,
confidence > 0 else {
return
}
switch domain {
case .calendar:
if analysis.hasDateOrTime {
boost(AIClipboardSkillCatalog.extractEventsID, 20)
}
case .travel:
if analysis.hasAddress {
boost(AIClipboardSkillCatalog.navigateID, 20)
}
case .media, .generalKnowledge:
if sourceText.count >= longTextCharacterThreshold {
boost(AIClipboardSkillCatalog.summarizeID, 15)
}
case .communication:
if !suppressesInterpersonalRouting,
hasInterpersonalRoutingEvidence(analysis) {
boost(AIClipboardSkillCatalog.replyID, 15)
}
case .finance, .accountService:
if !suppressesInterpersonalRouting,
isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.complaint) {
boost(AIClipboardSkillCatalog.replyID, 10)
}
case .smartHome, .shopping, .dining, .health, .weather:
return
}
}
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 isRoutingEvidence(_ label: ClipboardIntentLabel) -> Bool {
label.isDetected && label.isApprovedForAutomaticRouting
}
private static func isDisplayEvidence(_ label: ClipboardIntentLabel) -> Bool {
label.isDetected
&& label.confidence > 0
&& label.confidence >= label.threshold
}
private static func suppressesInterpersonalRouting(
_ analysis: ClipboardSemanticAnalysis
) -> Bool {
isDisplayEvidence(analysis.assistantCommand)
|| isDisplayEvidence(analysis.informationQuery)
|| isDisplayEvidence(analysis.systemNotification)
}
private static func hasInterpersonalRoutingEvidence(
_ analysis: ClipboardSemanticAnalysis
) -> Bool {
isRoutingEvidence(analysis.task)
|| isRoutingEvidence(analysis.question)
|| isRoutingEvidence(analysis.invitation)
|| isRoutingEvidence(analysis.complaint)
|| isRoutingEvidence(analysis.replyableMessage)
|| isRoutingEvidence(analysis.scheduleNegotiation)
|| isRoutingEvidence(analysis.confirmationDecision)
|| isRoutingEvidence(analysis.followUpReminder)
|| isRoutingEvidence(analysis.blessing)
}
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) {
$0 + effectiveLength($1)
} / Double(lines.count)
return lines.count >= 3 && averageLength <= listLineLengthThreshold
}
/// CJK characters carry roughly twice the information of a Latin character,
/// so a raw `count` makes every length threshold fire about twice too late
/// in Chinese and too early in English. Weight dense scripts instead.
private static func effectiveLength(_ text: String) -> Double {
text.reduce(into: 0.0) { total, character in
total += isDenseScriptCharacter(character) ? denseScriptCharacterWeight : 1
}
}
private static func isDenseScriptCharacter(_ character: Character) -> Bool {
guard let scalar = character.unicodeScalars.first else { return false }
switch scalar.value {
case 0x3040...0x30FF, // kana
0x3400...0x4DBF, // CJK unified ideographs extension A
0x4E00...0x9FFF, // CJK unified ideographs
0xAC00...0xD7AF, // hangul syllables
0xF900...0xFAFF, // CJK compatibility ideographs
0x20000...0x2FA1F: // CJK unified ideographs extensions B+
return true
default:
return false
}
}
private static func isWebLinkDominant(_ text: String, url: URL) -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
guard let hostRange = text.range(of: host, options: [.caseInsensitive]) else {
return significantCharacterCount(text) <= entityResidualLengthLimit
}
var start = hostRange.lowerBound
while start > text.startIndex {
let previous = text.index(before: start)
guard isURLCharacter(text[previous]) else { break }
start = previous
}
var end = hostRange.upperBound
while end < text.endIndex, isURLCharacter(text[end]) {
end = text.index(after: end)
}
var residual = text
residual.removeSubrange(start..<end)
return significantCharacterCount(residual) <= entityResidualLengthLimit
}
/// Digits belong to the number itself, so only letters count as residual.
private static func isPhoneNumberDominant(_ text: String) -> Bool {
text.reduce(into: 0) { count, character in
if character.isLetter { count += 1 }
} <= entityResidualLengthLimit
}
private static func significantCharacterCount(_ text: String) -> Int {
text.reduce(into: 0) { count, character in
if character.isLetter || character.isNumber { count += 1 }
}
}
private static func isURLCharacter(_ character: Character) -> Bool {
character.unicodeScalars.allSatisfy { urlCharacters.contains($0) }
}
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 analyzeText: @Sendable (String) async -> ClipboardSemanticAnalysis
private let shadowMetrics: ClipboardSemanticShadowMetricsStore?
private var analysisTask: Task<Void, Never>?
private var generation = UUID()
public init(
analyzer: ClipboardSemanticAnalyzer = ClipboardSemanticAnalyzer(),
shadowMetrics: ClipboardSemanticShadowMetricsStore = .shared
) {
analyzeText = { text in
await analyzer.analyze(text)
}
self.shadowMetrics = shadowMetrics
}
init(
analyzeText: @escaping @Sendable (String) async -> ClipboardSemanticAnalysis,
shadowMetrics: ClipboardSemanticShadowMetricsStore? = nil
) {
self.analyzeText = analyzeText
self.shadowMetrics = shadowMetrics
}
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.analyzeText(entry.text)
guard !Task.isCancelled, self.generation == expectedGeneration else { return }
self.shadowMetrics?.record(analysis)
self.snapshot = ClipboardSemanticRankingSnapshot(
entryID: entry.id,
analysis: analysis
)
}
}
public func clear() {
generation = UUID()
analysisTask?.cancel()
analysisTask = nil
snapshot = nil
}
}