Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.40% covered (warning)
81.40%
105 / 129
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LevelingUpManager
81.40% covered (warning)
81.40%
105 / 129
50.00% covered (danger)
50.00%
5 / 10
45.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 isEnabledForAnyone
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isEnabledForUser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 getTaskTypesGroupedByDifficulty
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 getTaskTypesOrderedByDifficultyLevel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 suggestNewTaskTypeForUser
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
10
 shouldInviteUserAfterNormalEdit
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
7.03
 getSuggestedEditsCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 shouldSendKeepGoingNotification
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldSendGetStartedNotification
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\LevelingUp;
4
5use GrowthExperiments\ExperimentUserManager;
6use GrowthExperiments\HomepageHooks;
7use GrowthExperiments\HomepageModules\SuggestedEdits;
8use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
9use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
10use GrowthExperiments\NewcomerTasks\Task\TaskSet;
11use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
12use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
13use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
14use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler;
15use GrowthExperiments\UserImpact\UserImpactLookup;
16use GrowthExperiments\VariantHooks;
17use MediaWiki\Config\Config;
18use MediaWiki\Config\ServiceOptions;
19use MediaWiki\Context\RequestContext;
20use MediaWiki\Storage\NameTableStore;
21use MediaWiki\User\Options\UserOptionsLookup;
22use MediaWiki\User\UserEditTracker;
23use MediaWiki\User\UserFactory;
24use MediaWiki\User\UserIdentity;
25use MediaWiki\User\UserIdentityValue;
26use Psr\Log\LoggerInterface;
27use RuntimeException;
28use Wikimedia\Rdbms\IConnectionProvider;
29use Wikimedia\Rdbms\IDBAccessObject;
30
31/**
32 * Manage the "leveling up" of a user, as the user progresses in completing suggested edit tasks.
33 */
34class 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}