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