Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.27% covered (success)
90.27%
102 / 113
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
LevelingUpManager
90.27% covered (success)
90.27%
102 / 113
50.00% covered (danger)
50.00%
4 / 8
30.83
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
 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.43% covered (success)
91.43%
32 / 35
0.00% covered (danger)
0.00%
0 / 1
7.03
 getSuggestedEditsCount
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 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\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
6use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
7use GrowthExperiments\NewcomerTasks\Task\TaskSet;
8use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
9use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
10use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
11use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler;
12use GrowthExperiments\UserImpact\UserImpactLookup;
13use IDBAccessObject;
14use MediaWiki\Config\Config;
15use MediaWiki\Config\ServiceOptions;
16use MediaWiki\Storage\NameTableStore;
17use MediaWiki\User\Options\UserOptionsLookup;
18use MediaWiki\User\UserEditTracker;
19use MediaWiki\User\UserFactory;
20use MediaWiki\User\UserIdentity;
21use MediaWiki\User\UserIdentityValue;
22use Psr\Log\LoggerInterface;
23use RequestContext;
24use Wikimedia\Rdbms\IConnectionProvider;
25
26/**
27 * Manage the "leveling up" of a user, as the user progresses in completing suggested edit tasks.
28 */
29class 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}