Skip to content

Commit 04e4f36

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

File tree

2 files changed

+109
-8
lines changed

2 files changed

+109
-8
lines changed

lib/model/autocomplete.dart

Lines changed: 81 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,39 @@ 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+
int matchLength = 0;
809+
while (
810+
matchLength < normalizedName.length && matchLength < _lowercase.length
811+
&& normalizedName[matchLength] == _lowercase[matchLength]
812+
) {
813+
matchLength++;
814+
}
815+
816+
if (matchLength == normalizedName.length && matchLength == _lowercase.length) {
817+
return NameMatchQuality.exact;
818+
} else if (matchLength == _lowercase.length) {
819+
return NameMatchQuality.totalPrefix;
820+
}
821+
822+
if (_testContainsQueryWords(normalizedNameWords)) {
823+
return NameMatchQuality.wordPrefixes;
824+
}
825+
826+
return null;
782827
}
783828

784829
/// A measure of a wildcard result's quality in the context of the query,
@@ -791,11 +836,17 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
791836
/// from 0 (best) to one less than [_numResultRanks].
792837
///
793838
/// See also [_rankWildcardResult].
794-
static int _rankUserResult(User user) => 1;
839+
static int _rankUserResult(User user, {required NameMatchQuality nameMatchQuality}) {
840+
return switch (nameMatchQuality) {
841+
NameMatchQuality.exact => 1,
842+
NameMatchQuality.totalPrefix => 2,
843+
NameMatchQuality.wordPrefixes => 3,
844+
};
845+
}
795846

796847
/// The number of possible values returned by
797848
/// [_rankWildcardResult] and [_rankUserResult].
798-
static const _numResultRanks = 2;
849+
static const _numResultRanks = 4;
799850

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

test/model/autocomplete_test.dart

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,11 +1014,37 @@ void main() {
10141014
checkPrecedes(user.fullName, WildcardMentionOption.channel, user);
10151015
});
10161016

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

0 commit comments

Comments
 (0)