Skip to content

Commit 19c1992

Browse files
chrisbobbegnprice
andcommitted
autocomplete: Rank user results by name-match quality
Co-authored-by: Greg Price <[email protected]>
1 parent 29778e0 commit 19c1992

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) {
@@ -724,6 +727,23 @@ abstract class AutocompleteQuery {
724727
}
725728
}
726729

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

771791
final cache = store.autocompleteViewManager.autocompleteDataCache;
772792
// TODO(#236) test email too, not just name
773-
if (!_testName(user, cache)) return null;
793+
final nameMatchQuality = _matchName(
794+
lowercaseName: cache.lowercaseNameForUser(user),
795+
lowercaseNameWords: cache.lowercaseNameWordsForUser(user));
796+
if (nameMatchQuality == null) return null;
774797

775798
return UserMentionAutocompleteResult(
776-
userId: user.userId, rank: _rankUserResult(user));
799+
userId: user.userId,
800+
rank: _rankUserResult(user, nameMatchQuality: nameMatchQuality));
777801
}
778802

779-
bool _testName(User user, AutocompleteDataCache cache) {
780-
return _testContainsQueryWords(cache.lowercaseNameWordsForUser(user));
803+
NameMatchQuality? _matchName({
804+
required String lowercaseName,
805+
required List<String> lowercaseNameWords,
806+
}) {
807+
int matchLength = 0;
808+
while (
809+
matchLength < lowercaseName.length && matchLength < _lowercase.length
810+
&& lowercaseName[matchLength] == _lowercase[matchLength]
811+
) {
812+
matchLength++;
813+
}
814+
815+
if (matchLength == lowercaseName.length && matchLength == _lowercase.length) {
816+
return NameMatchQuality.exact;
817+
} else if (matchLength == _lowercase.length) {
818+
return NameMatchQuality.totalPrefix;
819+
}
820+
821+
if (_testContainsQueryWords(lowercaseNameWords)) {
822+
return NameMatchQuality.wordPrefixes;
823+
}
824+
825+
return null;
781826
}
782827

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

795846
/// The number of possible values returned by
796847
/// [_rankWildcardResult] and [_rankUserResult].
797-
static const _numResultRanks = 2;
848+
static const _numResultRanks = 4;
798849

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

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)