Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
81.40% |
105 / 129 |
|
50.00% |
5 / 10 |
CRAP | |
0.00% |
0 / 1 |
LevelingUpManager | |
81.40% |
105 / 129 |
|
50.00% |
5 / 10 |
45.82 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
isEnabledForAnyone | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
isEnabledForUser | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
getTaskTypesGroupedByDifficulty | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
getTaskTypesOrderedByDifficultyLevel | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
suggestNewTaskTypeForUser | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
10 | |||
shouldInviteUserAfterNormalEdit | |
91.67% |
33 / 36 |
|
0.00% |
0 / 1 |
7.03 | |||
getSuggestedEditsCount | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
shouldSendKeepGoingNotification | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
shouldSendGetStartedNotification | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\LevelingUp; |
4 | |
5 | use GrowthExperiments\ExperimentUserManager; |
6 | use GrowthExperiments\HomepageHooks; |
7 | use GrowthExperiments\HomepageModules\SuggestedEdits; |
8 | use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader; |
9 | use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup; |
10 | use GrowthExperiments\NewcomerTasks\Task\TaskSet; |
11 | use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters; |
12 | use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory; |
13 | use GrowthExperiments\NewcomerTasks\TaskType\TaskType; |
14 | use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler; |
15 | use GrowthExperiments\UserImpact\UserImpactLookup; |
16 | use GrowthExperiments\VariantHooks; |
17 | use MediaWiki\Config\Config; |
18 | use MediaWiki\Config\ServiceOptions; |
19 | use MediaWiki\Context\RequestContext; |
20 | use MediaWiki\Storage\NameTableStore; |
21 | use MediaWiki\User\Options\UserOptionsLookup; |
22 | use MediaWiki\User\UserEditTracker; |
23 | use MediaWiki\User\UserFactory; |
24 | use MediaWiki\User\UserIdentity; |
25 | use MediaWiki\User\UserIdentityValue; |
26 | use Psr\Log\LoggerInterface; |
27 | use RuntimeException; |
28 | use Wikimedia\Rdbms\IConnectionProvider; |
29 | use Wikimedia\Rdbms\IDBAccessObject; |
30 | |
31 | /** |
32 | * Manage the "leveling up" of a user, as the user progresses in completing suggested edit tasks. |
33 | */ |
34 | class LevelingUpManager { |
35 | |
36 | /** |
37 | * A JSON-encoded array containing task type IDs. If a task type ID is present in this array, it means that the |
38 | * user has opted out from receiving prompts to try new task types when they are working on the given task type. |
39 | * e.g. a user is working on a "copyedit" task type, if "copyedit" is present in this preference, then the user |
40 | * should not be prompted to try another task type. |
41 | */ |
42 | public const TASK_TYPE_PROMPT_OPT_OUTS_PREF = 'growthexperiments-levelingup-tasktype-prompt-optouts'; |
43 | public const CONSTRUCTOR_OPTIONS = [ |
44 | 'GELevelingUpManagerTaskTypeCountThresholdMultiple', |
45 | 'GELevelingUpManagerInvitationThresholds', |
46 | 'GENewcomerTasksLinkRecommendationsEnabled', |
47 | ]; |
48 | |
49 | private ServiceOptions $options; |
50 | private IConnectionProvider $connectionProvider; |
51 | private NameTableStore $changeTagDefStore; |
52 | private UserOptionsLookup $userOptionsLookup; |
53 | private UserFactory $userFactory; |
54 | private UserEditTracker $userEditTracker; |
55 | private ConfigurationLoader $configurationLoader; |
56 | private UserImpactLookup $userImpactLookup; |
57 | private TaskSuggesterFactory $taskSuggesterFactory; |
58 | private NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup; |
59 | private LoggerInterface $logger; |
60 | private Config $growthConfig; |
61 | |
62 | /** |
63 | * @param ServiceOptions $options |
64 | * @param IConnectionProvider $connectionProvider |
65 | * @param NameTableStore $changeTagDefStore |
66 | * @param UserOptionsLookup $userOptionsLookup |
67 | * @param UserFactory $userFactory |
68 | * @param UserEditTracker $userEditTracker |
69 | * @param ConfigurationLoader $configurationLoader |
70 | * @param UserImpactLookup $userImpactLookup |
71 | * @param TaskSuggesterFactory $taskSuggesterFactory |
72 | * @param NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup |
73 | * @param LoggerInterface $logger |
74 | */ |
75 | public function __construct( |
76 | ServiceOptions $options, |
77 | IConnectionProvider $connectionProvider, |
78 | NameTableStore $changeTagDefStore, |
79 | UserOptionsLookup $userOptionsLookup, |
80 | UserFactory $userFactory, |
81 | UserEditTracker $userEditTracker, |
82 | ConfigurationLoader $configurationLoader, |
83 | UserImpactLookup $userImpactLookup, |
84 | TaskSuggesterFactory $taskSuggesterFactory, |
85 | NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup, |
86 | LoggerInterface $logger, |
87 | Config $growthConfig |
88 | ) { |
89 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
90 | $this->options = $options; |
91 | $this->connectionProvider = $connectionProvider; |
92 | $this->changeTagDefStore = $changeTagDefStore; |
93 | $this->userOptionsLookup = $userOptionsLookup; |
94 | $this->userFactory = $userFactory; |
95 | $this->userEditTracker = $userEditTracker; |
96 | $this->configurationLoader = $configurationLoader; |
97 | $this->userImpactLookup = $userImpactLookup; |
98 | $this->taskSuggesterFactory = $taskSuggesterFactory; |
99 | $this->newcomerTasksUserOptionsLookup = $newcomerTasksUserOptionsLookup; |
100 | $this->logger = $logger; |
101 | $this->growthConfig = $growthConfig; |
102 | } |
103 | |
104 | /** |
105 | * Are Levelling up features enabled for anyone? |
106 | * |
107 | * @param Config $config |
108 | * @return bool |
109 | */ |
110 | public static function isEnabledForAnyone( Config $config ): bool { |
111 | return SuggestedEdits::isEnabledForAnyone( $config ) |
112 | && $config->get( 'GELevelingUpFeaturesEnabled' ); |
113 | } |
114 | |
115 | /** |
116 | * Whether leveling up features are available. This does not include any checks based on |
117 | * the user's contribution history, just site configuration and A/B test cohorts. |
118 | * @param UserIdentity $user |
119 | * @param Config $config Site configuration. |
120 | * @param ExperimentUserManager $experimentUserManager |
121 | * @return bool |
122 | */ |
123 | public static function isEnabledForUser( |
124 | UserIdentity $user, |
125 | Config $config, |
126 | ExperimentUserManager $experimentUserManager |
127 | ): bool { |
128 | // Leveling up should only be shown if |
129 | // 1) suggested edits are available on this wiki, as we'll direct the user there |
130 | // 2) the user's homepage is enabled, which maybe SuggestedEdits::isEnabled should |
131 | // check, but it doesn't (this also excludes autocreated potentially-experienced |
132 | // users who probably shouldn't get invites) |
133 | // 3) (for now) the wiki is a pilot wiki and the user is in the experiment group |
134 | return self::isEnabledForAnyone( $config ) |
135 | && SuggestedEdits::isEnabled( $config ) |
136 | && HomepageHooks::isHomepageEnabled( $user ) |
137 | && $experimentUserManager->isUserInVariant( $user, VariantHooks::VARIANT_CONTROL ); |
138 | } |
139 | |
140 | /** |
141 | * Get the enabled task types, grouped by difficulty level. |
142 | * |
143 | * We use this method to assist in getting a list of task types ordered by difficulty level. We can't |
144 | * rely on the order in which they are returned by getTaskTypes(), because that is affected by the |
145 | * structure of the JSON on MediaWiki:NewcomerTasks.json |
146 | * |
147 | * @return array<string,string[]> |
148 | */ |
149 | public function getTaskTypesGroupedByDifficulty(): array { |
150 | $taskTypes = $this->configurationLoader->getTaskTypes(); |
151 | // HACK: "links" and "link-recommendation" are not loaded together. If link recommendation is enabled, |
152 | // remove "links" if it exists, and vice versa. |
153 | if ( $this->options->get( 'GENewcomerTasksLinkRecommendationsEnabled' ) ) { |
154 | if ( isset( $taskTypes['links'] ) ) { |
155 | unset( $taskTypes['links'] ); |
156 | } |
157 | } else { |
158 | if ( isset( $taskTypes['link-recommendation'] ) ) { |
159 | unset( $taskTypes['link-recommendation'] ); |
160 | } |
161 | } |
162 | $taskTypesGroupedByDifficulty = []; |
163 | foreach ( $taskTypes as $taskType ) { |
164 | $taskTypesGroupedByDifficulty[$taskType->getDifficulty()][] = $taskType->getId(); |
165 | } |
166 | $difficultyNumeric = array_flip( TaskType::DIFFICULTY_NUMERIC ); |
167 | uksort( $taskTypesGroupedByDifficulty, static function ( $a, $b ) use ( $difficultyNumeric ) { |
168 | return $difficultyNumeric[$a] - $difficultyNumeric[$b]; |
169 | } ); |
170 | return $taskTypesGroupedByDifficulty; |
171 | } |
172 | |
173 | /** |
174 | * Get the list of enabled task types, in order from least difficult to most difficult ("easy" tasks |
175 | * first, then "medium", then "difficult") |
176 | * |
177 | * @return string[] |
178 | */ |
179 | public function getTaskTypesOrderedByDifficultyLevel(): array { |
180 | // Flatten the task types grouped by difficulty. They'll be ordered by easiest to most difficult. |
181 | $taskTypes = []; |
182 | foreach ( $this->getTaskTypesGroupedByDifficulty() as $taskTypeIds ) { |
183 | $taskTypes = array_merge( $taskTypes, array_values( $taskTypeIds ) ); |
184 | } |
185 | return $taskTypes; |
186 | } |
187 | |
188 | /** |
189 | * Given an "active" task type ID (e.g. the task type associated with the article the user is about to edit), |
190 | * offer a suggestion for the next task type the user can try. |
191 | * |
192 | * The rules are: |
193 | * - suggest a new task type if the currently active task type will have been completed by a multiple of |
194 | * GELevelingUpManagerTaskTypeCountThresholdMultiple number of times; e.g. if the user has completed |
195 | * 4 copyedit tasks, and the active task type ID is copyedit, then we should prompt for a new task type. Same |
196 | * rule if the user has completed 9 copyedit tasks. |
197 | * - Only suggest task types that are known to have at least one candidate task available. This is expensive, |
198 | * so avoid calling this method in the critical path. |
199 | * - allow the user to opt out of receiving nudges for new task types, on a per task type |
200 | * basis (e.g. if the user likes to do copyedit task, they can opt out of getting nudges for trying new tasks, |
201 | * but if they switch to references, they would get a prompt to try another task type after the 5th reference |
202 | * edit) |
203 | * |
204 | * @param UserIdentity $userIdentity |
205 | * @param string $activeTaskTypeId The task type ID of the task that the user is currently working on. Examples: |
206 | * - the user clicked on a "copyedit" task type from Special:Homepage, then call this function with "copyedit" as |
207 | * the active task type ID. |
208 | * - the user just completed a newcomer task edit, and continues to edit the article in VisualEditor so there |
209 | * is no page reload, call this function with "copyedit" as the active task type ID. One could do this via an |
210 | * API call from ext.growthExperiments.suggestedEditSession in a post-edit hook on the client-side. |
211 | * @param bool $readLatest If user impact lookup should read from the primary database. |
212 | * @return string|null |
213 | */ |
214 | public function suggestNewTaskTypeForUser( |
215 | UserIdentity $userIdentity, string $activeTaskTypeId, bool $readLatest = false |
216 | ): ?string { |
217 | $flags = $readLatest ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL; |
218 | $userImpact = $this->userImpactLookup->getUserImpact( $userIdentity, $flags ); |
219 | if ( !$userImpact ) { |
220 | $this->logger->error( |
221 | 'Unable to fetch next suggested task type for user {userId}; no user impact found.', |
222 | [ 'userId' => $userIdentity->getId() ] |
223 | ); |
224 | return null; |
225 | } |
226 | |
227 | $editCountByTaskType = $userImpact->getEditCountByTaskType(); |
228 | $levelingUpTaskTypePromptOptOuts = $this->userOptionsLookup->getOption( |
229 | $userIdentity, |
230 | self::TASK_TYPE_PROMPT_OPT_OUTS_PREF, |
231 | '' |
232 | ); |
233 | $levelingUpTaskTypePromptOptOuts = json_decode( $levelingUpTaskTypePromptOptOuts ?? '', true ); |
234 | // Safety check, in case a user mangled their preference through mis-using the user options API. |
235 | if ( !is_array( $levelingUpTaskTypePromptOptOuts ) ) { |
236 | $levelingUpTaskTypePromptOptOuts = []; |
237 | } |
238 | if ( in_array( $activeTaskTypeId, $levelingUpTaskTypePromptOptOuts ) ) { |
239 | // User opted-out of receiving prompts to progress to another task type when on $activeTaskTypeId. |
240 | return null; |
241 | } |
242 | $levelingUpThreshold = $this->options->get( 'GELevelingUpManagerTaskTypeCountThresholdMultiple' ); |
243 | if ( ( $editCountByTaskType[$activeTaskTypeId] + 1 ) % $levelingUpThreshold !== 0 ) { |
244 | // Only trigger this on every 5th edit of the task type. |
245 | return null; |
246 | } |
247 | |
248 | $taskTypes = $this->getTaskTypesOrderedByDifficultyLevel(); |
249 | // Remove the active task type from the candidates. |
250 | $taskTypes = array_filter( $taskTypes, fn ( $item ) => $item !== $activeTaskTypeId ); |
251 | // Find any task type that has fewer than GELevelingUpManagerTaskTypeCountThresholdMultiple completed |
252 | // tasks, and offer it as the next task type. |
253 | $taskSuggester = $this->taskSuggesterFactory->create(); |
254 | $topicFilters = $this->newcomerTasksUserOptionsLookup->getTopics( $userIdentity ); |
255 | $topicMatchMode = $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $userIdentity ); |
256 | foreach ( $taskTypes as $candidateTaskTypeId ) { |
257 | if ( $editCountByTaskType[$candidateTaskTypeId] < $levelingUpThreshold ) { |
258 | // Validate that tasks exist for the task type (e.g. link-recommendation |
259 | // may exist as a task type, but there are zero items available in the task pool) |
260 | $suggestions = $taskSuggester->suggest( |
261 | new UserIdentityValue( 0, 'LevelingUpManager' ), |
262 | new TaskSetFilters( [ $candidateTaskTypeId ], $topicFilters, $topicMatchMode ), |
263 | 1, |
264 | null, |
265 | [ 'useCache' => false ] |
266 | ); |
267 | if ( $suggestions instanceof TaskSet && $suggestions->count() ) { |
268 | return $candidateTaskTypeId; |
269 | } |
270 | } |
271 | } |
272 | return null; |
273 | } |
274 | |
275 | /** |
276 | * Whether to show the user an invitation to try out suggested edits, right after the user did |
277 | * a normal edit. |
278 | * We show an invitation if the user's mainspace edit count (after the edit) is in |
279 | * $wgGELevelingUpManagerInvitationThresholds, and they did not make any suggested edit yet. |
280 | * @param UserIdentity $userIdentity |
281 | * @return bool |
282 | */ |
283 | public function shouldInviteUserAfterNormalEdit( UserIdentity $userIdentity ): bool { |
284 | $thresholds = $this->options->get( 'GELevelingUpManagerInvitationThresholds' ); |
285 | if ( !$thresholds ) { |
286 | return false; |
287 | } |
288 | |
289 | // Check total edit counts first, which is fast; don't bother checking users with many edits, |
290 | // for some arbitrary definition of "many". |
291 | // @phan-suppress-next-line PhanParamTooFewInternalUnpack |
292 | $quickThreshold = 3 * max( ...$thresholds ); |
293 | if ( $this->userEditTracker->getUserEditCount( $userIdentity ) > $quickThreshold ) { |
294 | return false; |
295 | } |
296 | |
297 | $wasPosted = RequestContext::getMain()->getRequest()->wasPosted(); |
298 | $db = $wasPosted |
299 | ? $this->connectionProvider->getPrimaryDatabase() |
300 | : $this->connectionProvider->getReplicaDatabase(); |
301 | |
302 | $user = $this->userFactory->newFromUserIdentity( $userIdentity ); |
303 | $tagId = $this->changeTagDefStore->acquireId( TaskTypeHandler::NEWCOMER_TASK_TAG ); |
304 | $editCounts = $db->newSelectQueryBuilder() |
305 | ->table( 'revision' ) |
306 | ->join( 'page', null, 'rev_page = page_id' ) |
307 | ->leftJoin( 'change_tag', null, [ 'rev_id = ct_rev_id', 'ct_tag_id' => $tagId ] ) |
308 | ->fields( [ |
309 | 'article_edits' => 'COUNT(*)', |
310 | 'suggested_edits' => 'COUNT(ct_rev_id)', |
311 | 'last_edit_timestamp' => 'MAX(rev_timestamp)', |
312 | ] ) |
313 | ->conds( [ |
314 | 'rev_actor' => $user->getActorId(), |
315 | 'page_namespace' => NS_MAIN, |
316 | // count deleted revisions for now |
317 | ] ) |
318 | // limit() not needed because of the edit count check above, and it would be somewhat |
319 | // complicated to combine it with COUNT() |
320 | ->caller( __METHOD__ ) |
321 | ->fetchRow(); |
322 | |
323 | if ( $editCounts->suggested_edits > 0 ) { |
324 | return false; |
325 | } |
326 | $articleEdits = (int)$editCounts->article_edits; |
327 | if ( !$wasPosted && $editCounts->last_edit_timestamp < $db->timestamp( time() - 3 ) ) { |
328 | // If the last edit was more than 5 seconds ago, we are probably not seeing the actual |
329 | // last edit due to replication lag. 5 is chosen arbitrarily to be large enough to |
330 | // account for slow saves and the VE reload, but small enough to account for the user |
331 | // making edits in quick succession. |
332 | $articleEdits++; |
333 | } |
334 | return in_array( $articleEdits, $thresholds, true ); |
335 | } |
336 | |
337 | /** |
338 | * Get the suggested edits count from the user's impact data. |
339 | * |
340 | * @param UserIdentity $userIdentity |
341 | * @return int |
342 | */ |
343 | public function getSuggestedEditsCount( UserIdentity $userIdentity ): int { |
344 | $impact = $this->userImpactLookup->getUserImpact( $userIdentity ); |
345 | if ( !$impact ) { |
346 | $this->logger->error( |
347 | 'Unable to fetch suggested edits count for user {userId}; no user impact found.', |
348 | [ |
349 | 'userId' => $userIdentity->getId(), |
350 | 'exception' => new RuntimeException, |
351 | ] |
352 | ); |
353 | return 0; |
354 | } |
355 | return $impact->getNewcomerTaskEditCount(); |
356 | } |
357 | |
358 | /** |
359 | * Whether to send the keep going notification to a user. |
360 | * |
361 | * Note that this only checks the edit thresholds; the event should be enqueued |
362 | * at account creation time with a job release timestamp of 48 hours. |
363 | * |
364 | * @param UserIdentity $userIdentity |
365 | * @return bool |
366 | */ |
367 | public function shouldSendKeepGoingNotification( UserIdentity $userIdentity ): bool { |
368 | $suggestedEditCount = $this->getSuggestedEditsCount( $userIdentity ); |
369 | $thresholds = $this->growthConfig->get( 'GELevelingUpKeepGoingNotificationThresholds' ); |
370 | return $suggestedEditCount >= $thresholds[0] && $suggestedEditCount <= $thresholds[1]; |
371 | } |
372 | |
373 | /** |
374 | * Whether to send the get started notification to a user. |
375 | * |
376 | * @param UserIdentity $userIdentity |
377 | * @return bool |
378 | */ |
379 | public function shouldSendGetStartedNotification( UserIdentity $userIdentity ): bool { |
380 | $maxEdits = (int)$this->growthConfig->get( 'GELevelingUpGetStartedMaxTotalEdits' ); |
381 | |
382 | return $this->getSuggestedEditsCount( $userIdentity ) === 0 && |
383 | $this->userEditTracker->getUserEditCount( $userIdentity ) < $maxEdits; |
384 | } |
385 | |
386 | } |