Skip to content

Commit 00c0840

Browse files
chrisbobbegnprice
andcommitted
autocomplete: Rank user results by name-match quality
Co-authored-by: Greg Price <[email protected]>
1 parent a6055d5 commit 00c0840

File tree

2 files changed

+105
-9
lines changed

2 files changed

+105
-9
lines changed

lib/model/autocomplete.dart

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
479479
required PerAccountStore store,
480480
required Narrow narrow,
481481
}) {
482+
// See also [MentionAutocompleteQuery._rankUserResult];
483+
// that ranking takes precedence over this.
484+
482485
int? streamId;
483486
TopicName? topic;
484487
switch (narrow) {
@@ -725,6 +728,23 @@ abstract class AutocompleteQuery {
725728
}
726729
}
727730

731+
/// The match quality of a [User.fullName] to a mention autocomplete query.
732+
///
733+
/// All matches are case-insensitive.
734+
enum NameMatchQuality {
735+
/// The query matches the whole name exactly.
736+
exact,
737+
738+
/// The name starts with the query.
739+
totalPrefix,
740+
741+
/// All of the query's words have matches in the words of the name
742+
/// that appear in order.
743+
///
744+
/// A "match" means the word in the name starts with the query word.
745+
wordPrefixes,
746+
}
747+
728748
/// Any autocomplete query in the compose box's content input.
729749
abstract class ComposeAutocompleteQuery extends AutocompleteQuery {
730750
ComposeAutocompleteQuery(super.raw);
@@ -771,14 +791,33 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
771791

772792
final cache = store.autocompleteViewManager.autocompleteDataCache;
773793
// TODO(#236) test email too, not just name
774-
if (!_testName(user, cache)) return null;
794+
final nameMatchQuality = _matchName(
795+
normalizedName: cache.normalizedNameForUser(user),
796+
normalizedNameWords: cache.normalizedNameWordsForUser(user));
797+
if (nameMatchQuality == null) return null;
775798

776799
return UserMentionAutocompleteResult(
777-
userId: user.userId, rank: _rankUserResult(user));
800+
userId: user.userId,
801+
rank: _rankUserResult(user, nameMatchQuality: nameMatchQuality));
778802
}
779803

780-
bool _testName(User user, AutocompleteDataCache cache) {
781-
return _testContainsQueryWords(cache.normalizedNameWordsForUser(user));
804+
NameMatchQuality? _matchName({
805+
required String normalizedName,
806+
required List<String> normalizedNameWords,
807+
}) {
808+
if (normalizedName.startsWith(_lowercase)) {
809+
if (normalizedName.length == _lowercase.length) {
810+
return NameMatchQuality.exact;
811+
} else {
812+
return NameMatchQuality.totalPrefix;
813+
}
814+
}
815+
816+
if (_testContainsQueryWords(normalizedNameWords)) {
817+
return NameMatchQuality.wordPrefixes;
818+
}
819+
820+
return null;
782821
}
783822

784823
/// A measure of a wildcard result's quality in the context of the query,
@@ -791,11 +830,17 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
791830
/// from 0 (best) to one less than [_numResultRanks].
792831
///
793832
/// See also [_rankWildcardResult].
794-
static int _rankUserResult(User user) => 1;
833+
static int _rankUserResult(User user, {required NameMatchQuality nameMatchQuality}) {
834+
return switch (nameMatchQuality) {
835+
NameMatchQuality.exact => 1,
836+
NameMatchQuality.totalPrefix => 2,
837+
NameMatchQuality.wordPrefixes => 3,
838+
};
839+
}
795840

796841
/// The number of possible values returned by
797842
/// [_rankWildcardResult] and [_rankUserResult].
798-
static const _numResultRanks = 2;
843+
static const _numResultRanks = 4;
799844

800845
@override
801846
String toString() {
@@ -888,6 +933,30 @@ sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {
888933
/// A measure of the result's quality in the context of the query.
889934
///
890935
/// Used internally by [MentionAutocompleteView] for ranking the results.
936+
// See also [MentionAutocompleteView._usersByRelevance];
937+
// results with equal [rank] will appear in the order they were put in
938+
// by that method.
939+
//
940+
// Compare sort_recipients in Zulip web:
941+
// https://github.com/zulip/zulip/blob/afdf20c67/web/src/typeahead_helper.ts#L472
942+
//
943+
// Behavior we have that web doesn't and might like to follow:
944+
// - A "word-prefixes" match quality on user names:
945+
// see [NameMatchQuality.wordPrefixes], which we rank on.
946+
//
947+
// Behavior web has that seems undesired, which we don't plan to follow:
948+
// - Ranking humans above bots, even when the bots have higher relevance
949+
// and better match quality. If there's a bot participating in the
950+
// current conversation and I start typing its name, why wouldn't we want
951+
// that as a top result? Issue: https://github.com/zulip/zulip/issues/35467
952+
// - A "word-boundary" match quality on user and user-group names:
953+
// special rank when the whole query appears contiguously
954+
// right after a word-boundary character.
955+
// Our [NameMatchQuality.wordPrefixes] seems smarter.
956+
// - A "word-boundary" match quality on user emails:
957+
// "words" is a wrong abstraction when matching on emails.
958+
// - Ranking some case-sensitive matches differently from case-insensitive
959+
// matches. Users will expect a lowercase query to be adequate.
891960
int get rank;
892961
}
893962

test/model/autocomplete_test.dart

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,8 @@ void main() {
785785
eg.dmMessage(from: users[4-1], to: [eg.selfUser]),
786786
]);
787787

788-
// Check the ranking of the full list of mentions.
788+
// Check the ranking of the full list of mentions,
789+
// i.e. the results for an empty query.
789790
// The order should be:
790791
// 1. Wildcards before individual users.
791792
// 2. Users most recent in the current topic/stream.
@@ -1012,11 +1013,37 @@ void main() {
10121013
checkPrecedes(user.fullName, WildcardMentionOption.channel, user);
10131014
});
10141015

1016+
test('user name matched case-insensitively', () {
1017+
final user1 = eg.user(fullName: 'Chris Bobbe');
1018+
final user2 = eg.user(fullName: 'chris bobbe');
1019+
1020+
checkSameRank('chris bobbe', user1, user2); // exact
1021+
checkSameRank('chris bo', user1, user2); // total-prefix
1022+
checkSameRank('chr bo', user1, user2); // word-prefixes
1023+
});
1024+
1025+
test('user name match: exact over total-prefix', () {
1026+
final user1 = eg.user(fullName: 'Chris');
1027+
final user2 = eg.user(fullName: 'Chris Bobbe');
1028+
1029+
checkPrecedes('chris', user1, user2);
1030+
});
1031+
1032+
test('user name match: total-prefix over word-prefixes', () {
1033+
final user1 = eg.user(fullName: 'So Many Ideas');
1034+
final user2 = eg.user(fullName: 'Some Merry User');
1035+
1036+
checkPrecedes('so m', user1, user2);
1037+
});
1038+
10151039
test('full list of ranks', () {
1040+
final user1 = eg.user(fullName: 'some user');
10161041
check([
10171042
rankOf('', WildcardMentionOption.all), // wildcard
1018-
rankOf('', eg.user()), // user
1019-
]).deepEquals([0, 1]);
1043+
rankOf('some user', user1), // user, exact name match
1044+
rankOf('some us', user1), // user, total-prefix name match
1045+
rankOf('so us', user1), // user, word-prefixes name match
1046+
]).deepEquals([0, 1, 2, 3]);
10201047
});
10211048
});
10221049

0 commit comments

Comments
 (0)