Merge lp:~wgrant/launchpad/picker-sorting into lp:launchpad

Proposed by William Grant
Status: Merged
Approved by: William Grant
Approved revision: no longer in the source branch.
Merged at revision: 13210
Proposed branch: lp:~wgrant/launchpad/picker-sorting
Merge into: lp:launchpad
Diff against target: 352 lines (+176/-16)
5 files modified
lib/lp/bugs/javascript/bugtask_index.js (+2/-2)
lib/lp/registry/tests/test_person_vocabularies.py (+99/-4)
lib/lp/registry/tests/test_product_vocabularies.py (+26/-0)
lib/lp/registry/vocabularies.py (+45/-10)
lib/lp/services/features/flags.py (+4/-0)
To merge this branch: bzr merge lp:~wgrant/launchpad/picker-sorting
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Review via email: mp+63975@code.launchpad.net

Commit message

[r=jcsackett][bug=172702,255282,504676,722412,787578] Sort the project picker by relevance, and the person picker by affiliation.

Description of the change

The project and person pickers are hilariously bad at returning relevant matches. Until recently, the person picker found all even slightly relevant results and sorted them alphabetically. It's now slightly better, but the project picker still does it alphabetically. This branch hopefully fixes all these problems.

The person picker is now context-sensitive. If the context is within a pillar, people who have touched the project or distribution receive a rank bonus, boosting them towards the top of the results. For this purpose, "touched the project" is defined as having at least 10 karma in the relevant context. The scaling factor is the base-10 logarithm of the karma value, as that seems to work well on dogfood for both large and small projects, with wide varieties of contribution levels. That's all easily tweakable later, at any rate. Surprisingly, it doesn't seem to perform too badly either.

I also downscaled the hardcoded ranks for exact/prefix matches of some person attributes. Their ordering was not changed. The new affiliation bonus is behind its own feature flag, inside the existing new ranking algorithm which is behind another.

This has the downside of not working for teams, as they do not have karma. While there are some possible paths for investigation (eg. average karma of the members), teams names tend to be less ambiguous and more easily searchable than people, so it's probably OK as it is.

The project picker is a bit simpler. It adds a new, flagged ranking algorithm that is a bit less useless than displayname. An exact name match is first, followed by ranked FTI matches. This seems to work pretty well. Just like teams, I found that affiliation ranking was less necessary here, as project names tend to be far more unique.

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :

William--

This looks good to land.

Revision history for this message
j.c.sackett (jcsackett) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
2--- lib/lp/bugs/javascript/bugtask_index.js 2011-05-12 02:09:42 +0000
3+++ lib/lp/bugs/javascript/bugtask_index.js 2011-06-09 09:46:25 +0000
4@@ -705,8 +705,8 @@
5 conf.bugtask_path,
6 "target_link",
7 bugtarget_content.get('id'),
8- {"step_title": "Search products",
9- "header": "Change product"});
10+ {"step_title": "Search projects",
11+ "header": "Change project"});
12 }
13 }
14
15
16=== modified file 'lib/lp/registry/tests/test_person_vocabularies.py'
17--- lib/lp/registry/tests/test_person_vocabularies.py 2010-12-02 19:45:50 +0000
18+++ lib/lp/registry/tests/test_person_vocabularies.py 2011-06-09 09:46:25 +0000
19@@ -6,16 +6,29 @@
20 __metaclass__ = type
21
22 from storm.store import Store
23+from zope.component import getUtility
24 from zope.schema.vocabulary import getVocabularyRegistry
25 from zope.security.proxy import removeSecurityProxy
26
27 from canonical.launchpad.ftests import login_person
28-from canonical.testing.layers import DatabaseFunctionalLayer
29+from canonical.testing.layers import (
30+ DatabaseFunctionalLayer,
31+ LaunchpadZopelessLayer,
32+ )
33 from lp.registry.interfaces.person import (
34 PersonVisibility,
35 TeamSubscriptionPolicy,
36 )
37+from lp.registry.interfaces.karma import IKarmaCacheManager
38+from lp.services.features.testing import FeatureFixture
39 from lp.testing import TestCaseWithFactory
40+from lp.testing.dbuser import dbuser
41+
42+
43+PERSON_AFFILIATION_RANK_FLAG = {
44+ 'disclosure.picker_enhancements.enabled': 'on',
45+ 'disclosure.person_affiliation_rank.enabled': 'on',
46+ }
47
48
49 class VocabularyTestBase:
50@@ -30,10 +43,90 @@
51 return self.vocabulary_registry.get(context, self.vocabulary_name)
52
53 def searchVocabulary(self, context, text):
54- Store.of(context).flush()
55+ if Store.of(context) is not None:
56+ Store.of(context).flush()
57 vocabulary = self.getVocabulary(context)
58 return removeSecurityProxy(vocabulary)._doSearch(text)
59
60+
61+class TestValidPersonOrTeamVocabulary(VocabularyTestBase,
62+ TestCaseWithFactory):
63+ """Test that the ValidPersonOrTeamVocabulary behaves as expected.
64+
65+ Most tests are in lib/lp/registry/doc/vocabularies.txt.
66+ """
67+
68+ layer = LaunchpadZopelessLayer
69+ vocabulary_name = 'ValidPersonOrTeam'
70+
71+ def addKarma(self, person, value, product=None, distribution=None):
72+ if product:
73+ kwargs = dict(product_id=product.id)
74+ elif distribution:
75+ kwargs = dict(distribution_id=distribution.id)
76+ with dbuser('karma'):
77+ getUtility(IKarmaCacheManager).new(
78+ value, person.id, None, **kwargs)
79+
80+ def test_people_with_karma_sort_higher(self):
81+ self.useFixture(FeatureFixture(PERSON_AFFILIATION_RANK_FLAG))
82+ exact_person = self.factory.makePerson(
83+ name='fooix', displayname='Fooix Bar')
84+ prefix_person = self.factory.makePerson(
85+ name='fooix-bar', displayname='Fooix Bar')
86+ contributor_person = self.factory.makePerson(
87+ name='bar', displayname='Fooix Bar')
88+ product = self.factory.makeProduct()
89+
90+ # Exact is better than prefix is better than FTI.
91+ self.assertEqual(
92+ [exact_person, prefix_person, contributor_person],
93+ list(self.searchVocabulary(product, u'fooix')))
94+
95+ # But karma can bump people up, behind the exact match.
96+ self.addKarma(contributor_person, 500, product=product)
97+ self.assertEqual(
98+ [exact_person, contributor_person, prefix_person],
99+ list(self.searchVocabulary(product, u'fooix')))
100+
101+ self.addKarma(prefix_person, 500, product=product)
102+ self.assertEqual(
103+ [exact_person, prefix_person, contributor_person],
104+ list(self.searchVocabulary(product, u'fooix')))
105+
106+ def assertKarmaContextConstraint(self, expected, context):
107+ """Check that the karma context constraint works.
108+
109+ Confirms that the karma context constraint matches the expected
110+ value, and that a search with it works.
111+ """
112+ if expected is not None:
113+ expected = expected % context.id
114+ self.assertEquals(
115+ expected,
116+ removeSecurityProxy(
117+ self.getVocabulary(context))._karma_context_constraint)
118+ with FeatureFixture(PERSON_AFFILIATION_RANK_FLAG):
119+ self.searchVocabulary(context, 'foo')
120+
121+ def test_product_karma_context(self):
122+ self.assertKarmaContextConstraint(
123+ 'product = %d', self.factory.makeProduct())
124+
125+ def test_project_karma_context(self):
126+ self.assertKarmaContextConstraint(
127+ 'project = %d', self.factory.makeProject())
128+
129+ def test_distribution_karma_context(self):
130+ self.assertKarmaContextConstraint(
131+ 'distribution = %d', self.factory.makeDistribution())
132+
133+ def test_root_karma_context(self):
134+ self.assertKarmaContextConstraint(None, None)
135+
136+
137+class TeamMemberVocabularyTestBase(VocabularyTestBase):
138+
139 def test_open_team_cannot_be_a_member_of_a_closed_team(self):
140 context_team = self.factory.makeTeam(
141 subscription_policy=TeamSubscriptionPolicy.MODERATED)
142@@ -88,7 +181,8 @@
143 vocabulary.step_title)
144
145
146-class TestValidTeamMemberVocabulary(VocabularyTestBase, TestCaseWithFactory):
147+class TestValidTeamMemberVocabulary(TeamMemberVocabularyTestBase,
148+ TestCaseWithFactory):
149 """Test that the ValidTeamMemberVocabulary behaves as expected."""
150
151 layer = DatabaseFunctionalLayer
152@@ -109,7 +203,8 @@
153 self.assertNotIn(team, self.searchVocabulary(team, team.name))
154
155
156-class TestValidTeamOwnerVocabulary(VocabularyTestBase, TestCaseWithFactory):
157+class TestValidTeamOwnerVocabulary(TeamMemberVocabularyTestBase,
158+ TestCaseWithFactory):
159 """Test that the ValidTeamOwnerVocabulary behaves as expected."""
160
161 layer = DatabaseFunctionalLayer
162
163=== modified file 'lib/lp/registry/tests/test_product_vocabularies.py'
164--- lib/lp/registry/tests/test_product_vocabularies.py 2010-12-22 20:44:25 +0000
165+++ lib/lp/registry/tests/test_product_vocabularies.py 2011-06-09 09:46:25 +0000
166@@ -7,12 +7,16 @@
167
168 from canonical.testing.layers import DatabaseFunctionalLayer
169 from lp.registry.vocabularies import ProductVocabulary
170+from lp.services.features.testing import FeatureFixture
171 from lp.testing import (
172 celebrity_logged_in,
173 TestCaseWithFactory,
174 )
175
176
177+PICKER_ENHANCEMENTS_FLAG = {'disclosure.picker_enhancements.enabled': 'on'}
178+
179+
180 class TestProductVocabulary(TestCaseWithFactory):
181 """Test that the ProductVocabulary behaves as expected."""
182 layer = DatabaseFunctionalLayer
183@@ -56,6 +60,28 @@
184 self.assertEqual(
185 [a_product, z_product, self.product], list(result))
186
187+ def test_order_by_relevance(self):
188+ # When the flag is enabled, the most relevant result is first.
189+ bar_product = self.factory.makeProduct(
190+ name='foo-bar', displayname='Foo bar', summary='quux')
191+ quux_product = self.factory.makeProduct(
192+ name='foo-quux', displayname='Foo quux')
193+ with FeatureFixture(PICKER_ENHANCEMENTS_FLAG):
194+ result = self.vocabulary.search('quux')
195+ self.assertEqual(
196+ [quux_product, bar_product], list(result))
197+
198+ def test_exact_match_is_first(self):
199+ # When the flag is enabled, an exact name match always wins.
200+ the_quux_product = self.factory.makeProduct(
201+ name='the-quux', displayname='The quux')
202+ quux_product = self.factory.makeProduct(
203+ name='quux', displayname='The quux')
204+ with FeatureFixture(PICKER_ENHANCEMENTS_FLAG):
205+ result = self.vocabulary.search('quux')
206+ self.assertEqual(
207+ [quux_product, the_quux_product], list(result))
208+
209 def test_inactive_products_are_excluded(self):
210 # Inactive products are not in the vocabulary.
211 with celebrity_logged_in('registry_experts'):
212
213=== modified file 'lib/lp/registry/vocabularies.py'
214--- lib/lp/registry/vocabularies.py 2011-06-09 08:07:52 +0000
215+++ lib/lp/registry/vocabularies.py 2011-06-09 09:46:25 +0000
216@@ -115,6 +115,7 @@
217 IStoreSelector,
218 MAIN_STORE,
219 )
220+from canonical.launchpad.webapp.publisher import nearest
221 from canonical.launchpad.webapp.vocabulary import (
222 BatchedCountableIterator,
223 CountableIterator,
224@@ -153,7 +154,10 @@
225 ITeam,
226 PersonVisibility,
227 )
228-from lp.registry.interfaces.pillar import IPillarName
229+from lp.registry.interfaces.pillar import (
230+ IPillar,
231+ IPillarName,
232+ )
233 from lp.registry.interfaces.product import (
234 IProduct,
235 IProductSet,
236@@ -194,6 +198,7 @@
237 _table = Person
238
239 def __init__(self, context=None):
240+ super(BasePersonVocabulary, self).__init__(context)
241 self.enhanced_picker_enabled = bool(
242 getFeatureFlag('disclosure.picker_enhancements.enabled'))
243
244@@ -285,7 +290,14 @@
245 fti_query = quote(query)
246 sql = "active = 't' AND (name LIKE %s OR fti @@ ftq(%s))" % (
247 like_query, fti_query)
248- return self._table.select(sql, orderBy=self._orderBy)
249+ if getFeatureFlag('disclosure.picker_enhancements.enabled'):
250+ order_by = (
251+ '(CASE name WHEN %s THEN 1 '
252+ ' ELSE rank(fti, ftq(%s)) END) DESC, displayname, name'
253+ % (fti_query, fti_query))
254+ else:
255+ order_by = self._orderBy
256+ return self._table.select(sql, orderBy=order_by, limit=100)
257 return self.emptySelectResults()
258
259
260@@ -489,6 +501,19 @@
261 """The storm store."""
262 return getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
263
264+ @cachedproperty
265+ def _karma_context_constraint(self):
266+ context = nearest(self.context, IPillar)
267+ if IProduct.providedBy(context):
268+ karma_context_column = 'product'
269+ elif IDistribution.providedBy(context):
270+ karma_context_column = 'distribution'
271+ elif IProjectGroup.providedBy(context):
272+ karma_context_column = 'project'
273+ else:
274+ return None
275+ return '%s = %d' % (karma_context_column, context.id)
276+
277 def _privateTeamQueryAndTables(self):
278 """Return query tables for private teams.
279
280@@ -722,8 +747,8 @@
281 SELECT Person.id,
282 (case
283 when person.name=? then 100
284- when lower(person.name) like ? || '%%' then 75
285- when lower(person.displayname) like ? || '%%' then 50
286+ when lower(person.name) like ? || '%%' then 5
287+ when lower(person.displayname) like ? || '%%' then 4
288 else rank(fti, ftq(?))
289 end) as rank
290 FROM Person
291@@ -731,12 +756,12 @@
292 or lower(Person.displayname) LIKE ? || '%%'
293 or Person.fti @@ ftq(?)
294 UNION ALL
295- SELECT Person.id, 25 AS rank
296+ SELECT Person.id, 3 AS rank
297 FROM Person, IrcID
298 WHERE Person.id = IrcID.person
299 AND IrcID.nickname = ?
300 UNION ALL
301- SELECT Person.id, 10 AS rank
302+ SELECT Person.id, 2 AS rank
303 FROM Person, EmailAddress
304 WHERE Person.id = EmailAddress.person
305 AND LOWER(EmailAddress.email) LIKE ? || '%%'
306@@ -753,8 +778,8 @@
307 private_ranking_sql = SQL("""
308 (case
309 when person.name=? then 100
310- when lower(person.name) like ? || '%%' then 75
311- when lower(person.displayname) like ? || '%%' then 50
312+ when lower(person.name) like ? || '%%' then 5
313+ when lower(person.displayname) like ? || '%%' then 3
314 else rank(fti, ftq(?))
315 end) as rank
316 """, (text, text, text, text))
317@@ -815,8 +840,18 @@
318 self.extra_clause),
319 )
320 # Better ranked matches go first.
321- result.order_by(
322- SQL("rank desc"), Person.displayname, Person.name)
323+ if (getFeatureFlag('disclosure.person_affiliation_rank.enabled')
324+ and self._karma_context_constraint):
325+ rank_order = SQL("""
326+ rank * COALESCE(
327+ (SELECT LOG(karmavalue) FROM KarmaCache
328+ WHERE person = Person.id AND
329+ %s
330+ AND category IS NULL AND karmavalue > 10),
331+ 1) DESC""" % self._karma_context_constraint)
332+ else:
333+ rank_order = SQL("rank DESC")
334+ result.order_by(rank_order, Person.displayname, Person.name)
335 result.config(limit=self.LIMIT)
336
337 # We will be displaying the person's irc nick(s) and emails in the
338
339=== modified file 'lib/lp/services/features/flags.py'
340--- lib/lp/services/features/flags.py 2011-06-06 13:54:07 +0000
341+++ lib/lp/services/features/flags.py 2011-06-09 09:46:25 +0000
342@@ -110,6 +110,10 @@
343 'boolean',
344 ('Enables the display of extra details in the person picker.'),
345 ''),
346+ ('disclosure.person_affiliation_rank.enabled',
347+ 'boolean',
348+ ('Enables ranking by pillar affiliation in the person picker.'),
349+ ''),
350 ])
351
352 # The set of all flag names that are documented.