Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions Sources/Support/SpeakerNameSelectionPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import Foundation

enum SpeakerNameSelectionPolicy {
static let ownerLabel = "You"

static func makeIdentityLabels<Option>(
for options: [Option],
id: (Option) -> UUID,
displayName: (Option) -> String,
callCount: (Option) -> Int
) -> (labels: [String], lookup: [String: Option]) {
let duplicateCounts = Dictionary(grouping: options, by: { normalizedSearchText(displayName($0)) })
.mapValues(\.count)
var lookup: [String: Option] = [:]

let labels = options.map { option in
let name = displayName(option)
let label: String
if duplicateCounts[normalizedSearchText(name), default: 0] > 1 {
let calls = callCount(option) == 1 ? "1 call" : "\(callCount(option)) calls"
label = "\(name) • \(calls) • \(id(option).uuidString.prefix(8))"
} else {
label = name
}
lookup[label] = option
return label
}

return (labels, lookup)
}

static func sortedLabels<Option>(
matching query: String,
labels: [String],
optionsByLabel: [String: Option],
displayName: (Option) -> String,
callCount: (Option) -> Int
) -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return labels }
return labels
.compactMap { label -> (label: String, rank: Int)? in
let rank = matchRank(for: label, query: trimmed, optionsByLabel: optionsByLabel, displayName: displayName)
guard rank < Int.max else { return nil }
return (label, rank)
}
.sorted { lhs, rhs in
if lhs.rank != rhs.rank { return lhs.rank < rhs.rank }
let lhsCalls = optionsByLabel[lhs.label].map(callCount) ?? 0
let rhsCalls = optionsByLabel[rhs.label].map(callCount) ?? 0
if lhsCalls != rhsCalls { return lhsCalls > rhsCalls }
return lhs.label.localizedStandardCompare(rhs.label) == .orderedAscending
}
.map { $0.label }
}

static func completedLabel<Option>(
for partial: String,
labels: [String],
optionsByLabel: [String: Option],
displayName: (Option) -> String,
callCount: (Option) -> Int
) -> String? {
let normalizedPartial = normalizedSearchText(partial)
guard !normalizedPartial.isEmpty else { return nil }

let matches = sortedLabels(
matching: partial,
labels: labels,
optionsByLabel: optionsByLabel,
displayName: displayName,
callCount: callCount
)

let prefixMatches = matches.filter { label in
let name = optionsByLabel[label].map(displayName) ?? label
return normalizedSearchText(name).hasPrefix(normalizedPartial)
|| normalizedSearchText(label).hasPrefix(normalizedPartial)
}

guard prefixMatches.count == 1 else { return nil }
return prefixMatches[0]
}

static func option<Option>(
matching input: String,
optionsByLabel: [String: Option],
displayName: (Option) -> String
) -> Option? {
if let exact = optionsByLabel[input] {
return exact
}

let normalizedInput = normalizedSearchText(input)
let displayMatches = optionsByLabel.values.filter {
normalizedSearchText(displayName($0)) == normalizedInput
}
guard displayMatches.count == 1 else { return nil }
return displayMatches[0]
}

static func isOwnerLabel(_ value: String) -> Bool {
normalizedSearchText(value) == normalizedSearchText(ownerLabel)
}

static func normalizedSearchText(_ value: String) -> String {
value
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
}

private static func matchRank<Option>(
for label: String,
query: String,
optionsByLabel: [String: Option],
displayName: (Option) -> String
) -> Int {
let normalizedQuery = normalizedSearchText(query)
guard !normalizedQuery.isEmpty else { return 0 }

let normalizedLabel = normalizedSearchText(label)
let name = optionsByLabel[label].map(displayName) ?? label
let normalizedDisplayName = normalizedSearchText(name)
let displayWords = normalizedDisplayName.split(separator: " ")

if normalizedDisplayName == normalizedQuery { return 0 }
if normalizedDisplayName.hasPrefix(normalizedQuery) { return 1 }
if displayWords.contains(where: { $0.hasPrefix(normalizedQuery) }) { return 2 }
if normalizedLabel.hasPrefix(normalizedQuery) { return 3 }
if normalizedDisplayName.contains(normalizedQuery) { return 4 }
if normalizedLabel.contains(normalizedQuery) { return 5 }
return Int.max
}
}
110 changes: 28 additions & 82 deletions Sources/UI/Settings/SpeakerNamingSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,11 +433,16 @@ final class SpeakerRowView: NSView {

init(entry: SpeakerNamingEntry, knownPeople: [SpeakerIdentityOption]) {
self.entry = entry
let optionLabels = Self.makeIdentityLabels(for: knownPeople.filter { $0.id != entry.id })
let optionLabels = SpeakerNameSelectionPolicy.makeIdentityLabels(
for: knownPeople.filter { $0.id != entry.id },
id: { $0.id },
displayName: { $0.displayName },
callCount: { $0.callCount }
)
self.knownPeopleByLabel = optionLabels.lookup
if entry.channel == .mic,
!optionLabels.labels.contains(where: { Self.normalizedSearchText($0) == "you" }) {
self.knownPeopleLabels = ["You"] + optionLabels.labels
!optionLabels.labels.contains(where: { SpeakerNameSelectionPolicy.isOwnerLabel($0) }) {
self.knownPeopleLabels = [SpeakerNameSelectionPolicy.ownerLabel] + optionLabels.labels
} else {
self.knownPeopleLabels = optionLabels.labels
}
Expand Down Expand Up @@ -511,7 +516,7 @@ final class SpeakerRowView: NSView {
nameField.usesDataSource = true
nameField.dataSource = self
nameField.delegate = self
nameField.completes = false
nameField.completes = true
nameField.numberOfVisibleItems = min(max(knownPeopleLabels.count, 4), 8)
nameField.font = NSFont.systemFont(ofSize: 12)
if entry.currentName != nil {
Expand Down Expand Up @@ -782,64 +787,21 @@ final class SpeakerRowView: NSView {
private func visibleKnownPeopleLabels() -> [String] {
let query = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return knownPeopleLabels }
return sortedKnownPeopleLabels(matching: query)
}

private func sortedKnownPeopleLabels(matching query: String) -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return knownPeopleLabels }
return knownPeopleLabels
.compactMap { label -> (label: String, rank: Int)? in
let rank = matchRank(for: label, query: trimmed)
guard rank < Int.max else { return nil }
return (label, rank)
}
.sorted { lhs, rhs in
if lhs.rank != rhs.rank { return lhs.rank < rhs.rank }
let lhsCalls = knownPeopleByLabel[lhs.label]?.callCount ?? 0
let rhsCalls = knownPeopleByLabel[rhs.label]?.callCount ?? 0
if lhsCalls != rhsCalls { return lhsCalls > rhsCalls }
return lhs.label.localizedStandardCompare(rhs.label) == .orderedAscending
}
.map { $0.label }
return SpeakerNameSelectionPolicy.sortedLabels(
matching: query,
labels: knownPeopleLabels,
optionsByLabel: knownPeopleByLabel,
displayName: { $0.displayName },
callCount: { $0.callCount }
)
}

private func knownPeopleOption(matching input: String) -> SpeakerIdentityOption? {
if let exact = knownPeopleByLabel[input] {
return exact
}

let normalizedInput = Self.normalizedSearchText(input)
let displayMatches = knownPeopleByLabel.values.filter {
Self.normalizedSearchText($0.displayName) == normalizedInput
}
guard displayMatches.count == 1 else { return nil }
return displayMatches[0]
}

private func matchRank(for label: String, query: String) -> Int {
let normalizedQuery = Self.normalizedSearchText(query)
guard !normalizedQuery.isEmpty else { return 0 }

let normalizedLabel = Self.normalizedSearchText(label)
let displayName = knownPeopleByLabel[label]?.displayName ?? label
let normalizedDisplayName = Self.normalizedSearchText(displayName)
let displayWords = normalizedDisplayName.split(separator: " ")

if normalizedDisplayName == normalizedQuery { return 0 }
if normalizedDisplayName.hasPrefix(normalizedQuery) { return 1 }
if displayWords.contains(where: { $0.hasPrefix(normalizedQuery) }) { return 2 }
if normalizedLabel.hasPrefix(normalizedQuery) { return 3 }
if normalizedDisplayName.contains(normalizedQuery) { return 4 }
if normalizedLabel.contains(normalizedQuery) { return 5 }
return Int.max
}

private static func normalizedSearchText(_ value: String) -> String {
value
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
SpeakerNameSelectionPolicy.option(
matching: input,
optionsByLabel: knownPeopleByLabel,
displayName: { $0.displayName }
)
}

private static func disableExpansionFrame(for field: NSTextField) {
Expand Down Expand Up @@ -876,28 +838,6 @@ final class SpeakerRowView: NSView {
}
return parts.joined(separator: " • ")
}

private static func makeIdentityLabels(
for options: [SpeakerIdentityOption]
) -> (labels: [String], lookup: [String: SpeakerIdentityOption]) {
let duplicateCounts = Dictionary(grouping: options, by: { $0.displayName.lowercased() })
.mapValues(\.count)
var lookup: [String: SpeakerIdentityOption] = [:]

let labels = options.map { option in
let label: String
if duplicateCounts[option.displayName.lowercased(), default: 0] > 1 {
let calls = option.callCount == 1 ? "1 call" : "\(option.callCount) calls"
label = "\(option.displayName) • \(calls) • \(option.id.uuidString.prefix(8))"
} else {
label = option.displayName
}
lookup[label] = option
return label
}

return (labels, lookup)
}
}

@available(macOS 14.0, *)
Expand All @@ -913,7 +853,13 @@ extension SpeakerRowView: NSComboBoxDataSource, NSComboBoxDelegate {
}

func comboBox(_ comboBox: NSComboBox, completedString string: String) -> String? {
nil
SpeakerNameSelectionPolicy.completedLabel(
for: string,
labels: knownPeopleLabels,
optionsByLabel: knownPeopleByLabel,
displayName: { $0.displayName },
callCount: { $0.callCount }
)
}

func comboBox(_ comboBox: NSComboBox, indexOfItemWithStringValue string: String) -> Int {
Expand Down
1 change: 1 addition & 0 deletions Tests/FastTests.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ MeetingSessionUIPolicyTests.swift:testMeetingSessionUIPolicy
MeetingWarmupStatusPolicyTests.swift:testMeetingWarmupStatusPolicy
MenuBarVisibilityPreferencesTests.swift:testMenuBarVisibilityPreferences
SpeakerNamingPolicyTests.swift:testSpeakerNamingPolicy
SpeakerNameSelectionPolicyTests.swift:testSpeakerNameSelectionPolicy
DictationTranscriptWriterTests.swift:testDictationTranscriptWriter
DictationTranscriptStoreTests.swift:testDictationTranscriptStore
DictationAutoSendPreferencesTests.swift:testDictationAutoSendPreferences
Expand Down
66 changes: 66 additions & 0 deletions Tests/SpeakerNameSelectionPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

func testSpeakerNameSelectionPolicy() {
runSuite("SpeakerNameSelectionPolicy suggests the only prefix match") {
let people = [
makeSpeakerIdentityOption(name: "Taylor Wolfe", calls: 9),
makeSpeakerIdentityOption(name: "Matt Bentley", calls: 4),
]
let labels = SpeakerNameSelectionPolicy.makeIdentityLabels(for: people, id: { $0.id }, displayName: { $0.displayName }, callCount: { $0.callCount })

let completion = SpeakerNameSelectionPolicy.completedLabel(
for: "tay",
labels: labels.labels,
optionsByLabel: labels.lookup,
displayName: { $0.displayName },
callCount: { $0.callCount }
)

assertEqual(completion, "Taylor Wolfe", "typing a unique prefix should auto-complete the matching speaker")
}

runSuite("SpeakerNameSelectionPolicy does not auto-complete ambiguous prefixes") {
let people = [
makeSpeakerIdentityOption(name: "Taylor Wolfe", calls: 9),
makeSpeakerIdentityOption(name: "Tanya Smith", calls: 7),
]
let labels = SpeakerNameSelectionPolicy.makeIdentityLabels(for: people, id: { $0.id }, displayName: { $0.displayName }, callCount: { $0.callCount })

let completion = SpeakerNameSelectionPolicy.completedLabel(
for: "ta",
labels: labels.labels,
optionsByLabel: labels.lookup,
displayName: { $0.displayName },
callCount: { $0.callCount }
)

assertNil(completion, "ambiguous prefixes should leave the user's typed text alone")
}

runSuite("SpeakerNameSelectionPolicy resolves typed display names without forcing dropdown labels") {
let id = UUID()
let people = [
SpeakerIdentityOption(id: id, displayName: "Taylor Wolfe", callCount: 3),
SpeakerIdentityOption(id: UUID(), displayName: "Taylor Wolfe", callCount: 1),
]
let labels = SpeakerNameSelectionPolicy.makeIdentityLabels(for: people, id: { $0.id }, displayName: { $0.displayName }, callCount: { $0.callCount })

let option = SpeakerNameSelectionPolicy.option(
matching: "Taylor Wolfe • 3 calls • \(id.uuidString.prefix(8))",
optionsByLabel: labels.lookup,
displayName: { $0.displayName }
)

assertEqual(option?.id, id, "duplicate-name labels should still map to their exact selected person")
}

runSuite("SpeakerNameSelectionPolicy recognizes the owner label") {
assertTrue(SpeakerNameSelectionPolicy.isOwnerLabel("You"), "exact owner label should match")
assertTrue(SpeakerNameSelectionPolicy.isOwnerLabel(" you "), "owner label should ignore case and surrounding whitespace")
assertFalse(SpeakerNameSelectionPolicy.isOwnerLabel("Young"), "nearby names should not collapse to the owner speaker")
}
}

private func makeSpeakerIdentityOption(name: String, calls: Int) -> SpeakerIdentityOption {
SpeakerIdentityOption(id: UUID(), displayName: name, callCount: calls)
}
1 change: 1 addition & 0 deletions scripts/entrypoints/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ APP_SOURCES=(
"Sources/TranscriptedCore/Models/TranscriptionTypes.swift"
"Sources/TranscriptedCore/Speaker/SpeakerProfile.swift"
"Sources/TranscriptedCore/Speaker/SpeakerNamingPolicy.swift"
"Sources/Support/SpeakerNameSelectionPolicy.swift"
"Sources/UI/Shared/AgentConnectionGuide.swift"
"Sources/UI/Shared/FeedbackIssueBuilder.swift"
"Sources/UI/Shared/SupportDiagnosticsBundle.swift"
Expand Down