Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.34% covered (danger)
2.34%
5 / 214
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
LiftWingService
2.34% covered (danger)
2.34%
5 / 214
15.38% covered (danger)
15.38%
2 / 13
2473.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFrontendBaseUrl
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 request
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 singleLiftWingRequest
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
110
 revertRiskLiftWingRequest
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
240
 prepareRevertriskRequest
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 createHostHeader
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 parseLiftWingResults
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 createRevisionNotFoundResponse
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 createRevisionNotScorableResponse
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 modifyRevertRiskResponse
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * This program is free software: you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation, either version 3 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 */
16
17namespace ORES;
18
19use MediaWiki\Config\Config;
20use MediaWiki\Http\HttpRequestFactory;
21use MediaWiki\Json\FormatJson;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Status\Status;
24use Psr\Log\LoggerInterface;
25use RuntimeException;
26
27/**
28 * Common methods for accessing a Lift Wing server.
29 */
30class LiftWingService extends ORESService {
31
32    public const API_VERSION = 1;
33    private Config $config;
34
35    public function __construct(
36        LoggerInterface $logger, HttpRequestFactory $httpRequestFactory, Config $config
37    ) {
38        parent::__construct( $logger, $httpRequestFactory );
39        $this->config = $config;
40    }
41
42    /**
43     * @return string Base URL of ORES service
44     */
45    public static function getBaseUrl() {
46        global $wgOresLiftWingBaseUrl;
47
48        return $wgOresLiftWingBaseUrl;
49    }
50
51    /**
52     * @return string Base URL of ORES service being used externally
53     */
54    public static function getFrontendBaseUrl() {
55        global $wgOresFrontendBaseUrl, $wgOresLiftWingBaseUrl;
56
57        if ( $wgOresFrontendBaseUrl === null ) {
58            return $wgOresLiftWingBaseUrl;
59        }
60
61        return $wgOresFrontendBaseUrl;
62    }
63
64    /**
65     * @param string|null $model
66     * @return string Base URL plus your wiki's `scores` API path.
67     */
68    public function getUrl( $model = null ) {
69        $wikiId = self::getWikiID();
70        $prefix = 'v' . self::API_VERSION;
71        $baseUrl = self::getBaseUrl();
72        return "{$baseUrl}{$prefix}/models/{$wikiId}-{$model}:predict";
73    }
74
75    /**
76     * Make an ORES API request and return the decoded result.
77     *
78     * @param array $params
79     * @param array|null $originalRequest
80     *
81     * @return array Decoded response
82     */
83    public function request( array $params, $originalRequest = null ) {
84        if ( !isset( $params['models'] ) ) {
85            throw new RuntimeException( 'Missing required parameter: models' );
86        }
87        if ( !isset( $params['revids'] ) ) {
88            throw new RuntimeException( 'Missing required parameter: revids' );
89        }
90
91        $models = explode( '|', $params['models'] );
92        $revids = explode( '|', $params['revids'] );
93
94        $responses = [];
95
96        foreach ( $models as $model ) {
97            foreach ( $revids as $revid ) {
98                if ( $model == 'revertrisklanguageagnostic' ) {
99                    // This is a hack to convert the revert-risk response to an ORES response in order to be compatible
100                    // with the rest of the model responses in order for them to be easily merged together in one
101                    // response.
102                    $response = $this->revertRiskLiftWingRequest( $model, $revid );
103                } else {
104                    $response = $this->singleLiftWingRequest( $model, $revid );
105                }
106
107                $responses[] = $response;
108            }
109        }
110        $wikiId = self::getWikiID();
111        return $this->parseLiftWingResults( $wikiId, $responses );
112    }
113
114    /**
115     * Make a single call to LW for one revid and one model and return the decoded result.
116     *
117     * @param string $model
118     * @param string $revid
119     *
120     * @return array Decoded response
121     */
122    public function singleLiftWingRequest( $model, $revid ) {
123        $url = $this->getUrl( $model );
124        $this->logger->debug( "Requesting: {$url}" );
125
126        $req = $this->httpRequestFactory->create( $url, [
127            'method' => 'POST',
128            'postData' => json_encode( [ 'rev_id' => (int)$revid ] ),
129        ], __METHOD__ );
130        global $wgOresLiftWingAddHostHeader;
131        if ( $wgOresLiftWingAddHostHeader ) {
132            $req->setHeader( 'Content-Type', 'application/json' );
133            $req->setHeader( 'Host', self::createHostHeader( $model ) );
134        }
135        $status = $req->execute();
136        if ( !$status->isOK() ) {
137            $message = "Failed to make LiftWing request to [{$url}], " .
138                Status::wrap( $status )->getMessage()->inLanguage( 'en' )->text();
139
140            // Server time out, try again once
141            if ( $req->getStatus() === 504 ) {
142                $req = $this->httpRequestFactory->create( $url, [
143                    'method' => 'POST',
144                    'postData' => json_encode( [ 'rev_id' => (int)$revid ] ),
145                ], __METHOD__ );
146                if ( $wgOresLiftWingAddHostHeader ) {
147                    $req->setHeader( 'Content-Type', 'application/json' );
148                    $req->setHeader( 'Host', self::createHostHeader( $model ) );
149                }
150
151                $status = $req->execute();
152                if ( !$status->isOK() ) {
153                    throw new RuntimeException( $message );
154                }
155            } elseif ( $req->getStatus() === 400 ) {
156                $this->logger->debug( "400 Bad Request: {$message}" );
157                $data = FormatJson::decode( $req->getContent(), true );
158                if ( strpos( $data["error"], "The MW API does not have any info related to the rev-id" ) === 0 ) {
159                    return $this->createRevisionNotFoundResponse( $model, $revid );
160                } else {
161                    throw new RuntimeException( $message );
162                }
163            } else {
164                throw new RuntimeException( $message );
165            }
166        }
167        $json = $req->getContent();
168        $this->logger->debug( "Raw response: {$json}" );
169        $data = FormatJson::decode( $json, true );
170        if ( !$data || !empty( $data['error'] ) ) {
171            throw new RuntimeException( "Bad response from Lift Wing endpoint [{$url}]: {$json}" );
172        }
173        return $data;
174    }
175
176    /**
177     * @param string $model
178     * @param string|int $revid
179     * @return array|array[]|mixed
180     */
181    public function revertRiskLiftWingRequest( $model, $revid ) {
182        $revLookup = MediaWikiServices::getInstance()->getRevisionLookup();
183        $revidInt = (int)$revid;
184        $revidStr = (string)$revid;
185        // The Language-agnostic revert risk model card states that
186        // the first or only revision on a page must be excluded.
187        if ( $revidInt === 0 ) {
188            return $this->createRevisionNotScorableResponse(
189                $model,
190                $revidStr
191            );
192        }
193        $rev = $revLookup->getRevisionById( $revidInt );
194        if ( $rev === null ) {
195            return $this->createRevisionNotFoundResponse(
196                $model,
197                $revidStr
198            );
199        }
200        $parentId = $rev->getParentId();
201        if ( $parentId === null || $parentId === 0 ) {
202            return $this->createRevisionNotScorableResponse(
203                $model,
204                $revidStr
205            );
206        }
207        $wikiId = self::getWikiID();
208        $language = substr( $wikiId, 0, strpos( $wikiId, "wiki" ) );
209        $prefix = 'v' . self::API_VERSION;
210        $baseUrl = self::getBaseUrl();
211        $url = "{$baseUrl}{$prefix}/models/revertrisk-language-agnostic:predict";
212
213        $this->logger->debug( "Requesting: {$url}" );
214
215        $req = $this->prepareRevertriskRequest( $url, $revid, $language );
216        $status = $req->execute();
217        if ( !$status->isOK() ) {
218            $message = "Failed to make LiftWing request to [{$url}], " .
219                Status::wrap( $status )->getMessage()->inLanguage( 'en' )->text();
220
221            // Server time out, try again once
222            if ( $req->getStatus() === 504 ) {
223                $req = $this->prepareRevertriskRequest( $url, $revid, $language );
224                $status = $req->execute();
225                if ( !$status->isOK() ) {
226                    throw new RuntimeException( $message );
227                }
228            } elseif ( $req->getStatus() === 400 ) {
229                $this->logger->debug( "400 Bad Request: {$message}" );
230                $data = FormatJson::decode( $req->getContent(), true );
231                // This is a way to detect whether an error occurred because the revision was not found.
232                // Checking for "detail" key in the response is a hack to detect the error message from Lift Wing as
233                // there is no universal error handling/messaging at the moment.
234                if ( ( $data && isset( $data['error'] ) && strpos( $data["error"],
235                        "The MW API does not have any info related to the rev-id" ) === 0 ) ||
236                    array_key_exists( "detail", $data ) ) {
237                    return $this->createRevisionNotFoundResponse(
238                        $model,
239                        $revidStr
240                    );
241                } else {
242                    throw new RuntimeException( $message );
243                }
244            } else {
245                throw new RuntimeException( $message );
246            }
247        }
248        $json = $req->getContent();
249        $this->logger->debug( "Raw response: {$json}" );
250        $data = FormatJson::decode( $json, true );
251        if ( !$data || !empty( $data['error'] ) ) {
252            throw new RuntimeException( "Bad response from Lift Wing endpoint [{$url}]: {$json}" );
253        }
254        return $this->modifyRevertRiskResponse( $data );
255    }
256
257    private function prepareRevertriskRequest( string $url, string $revid, string $language ): \MWHttpRequest {
258        $req = $this->httpRequestFactory->create( $url, [
259            'method' => 'POST',
260            'postData' => json_encode( [ 'rev_id' => (int)$revid, 'lang' => $language ] ),
261        ], __METHOD__ );
262        $req->setHeader( 'Content-Type', 'application/json' );
263        global $wgOresLiftWingAddHostHeader;
264        if ( $wgOresLiftWingAddHostHeader ) {
265            $req->setHeader( 'Host', $this->config->get( 'OresLiftWingRevertRiskHostHeader' ) );
266        }
267        return $req;
268    }
269
270    /**
271     * @param string $modelname the model name as requested from ORES
272     * @return string The hostname required in the header for the Lift Wing call
273     */
274    public static function createHostHeader( string $modelname ) {
275        $hostnames = [
276            'articlequality' => 'revscoring-articlequality',
277            'itemquality' => 'revscoring-articlequality',
278            'articletopic' => 'revscoring-articletopic',
279            'itemtopic' => 'revscoring-articletopic',
280            'draftquality' => 'revscoring-draftquality',
281            'drafttopic' => 'revscoring-drafttopic',
282            'damaging' => 'revscoring-editquality-damaging',
283            'goodfaith' => 'revscoring-editquality-goodfaith',
284            'reverted' => 'revscoring-editquality-reverted',
285        ];
286        $wikiID = self::getWikiID();
287        return "{$wikiID}-{$modelname}.{$hostnames[$modelname]}.wikimedia.org";
288    }
289
290    /**
291     * This function merges the multiple Lift Wing responses into the appropriate format as returned from ORES
292     * @param string $context
293     * @param array $responses
294     * @return array
295     */
296    private function parseLiftWingResults( string $context, array $responses ): array {
297        $result = [];
298        foreach ( $responses as $d ) {
299            if ( !$d ) {
300                continue;
301            }
302            foreach ( $d[$context] as $k => $v ) {
303                if ( is_array( $v ) && $k === "scores" ) {
304                    foreach ( $v as $rev_id => $scores ) {
305                        if ( isset( $result[$context][$k][$rev_id] ) ) {
306                            $result[$context][$k][$rev_id] = array_merge( $result[$context][$k][$rev_id], $scores );
307                        } else {
308                            $result[$context][$k][$rev_id] = $scores;
309                        }
310                    }
311                } else {
312                    $result[$context][$k] = array_merge( $result[$context][$k] ?? [], $v );
313                }
314            }
315        }
316        return $result;
317    }
318
319    /**
320     * @param string $model_name
321     * @param string $rev_id
322     * @return array
323     */
324    private function createRevisionNotFoundResponse(
325        string $model_name,
326        string $rev_id
327    ) {
328        global $wgOresModelVersions;
329        $error_type = "RevisionNotFound";
330        $error_message = "{$error_type}: Could not find revision ({revision}:{$rev_id})";
331        return [
332            self::getWikiID() => [
333                "models" => [
334                    $model_name => [
335                        "version" => $wgOresModelVersions['models'][$model_name]['version'],
336                    ],
337                ],
338                "scores" => [
339                    $rev_id => [
340                        $model_name => [
341                            "error" => [
342                                "message" => $error_message,
343                                "type" => $error_type,
344                            ],
345                        ],
346                    ],
347                ],
348            ],
349        ];
350    }
351
352    /**
353     * @param string $model_name
354     * @param string $rev_id
355     * @return array
356     */
357    private function createRevisionNotScorableResponse(
358        string $model_name,
359        string $rev_id
360    ) {
361        global $wgOresModelVersions;
362        $error_type = "RevisionNotScorable";
363        $error_message = "{$error_type}: This model may not be used to score ({revision}:{$rev_id}).";
364        $error_message .= " See Users and uses section of model card.";
365        return [
366            self::getWikiID() => [
367                "models" => [
368                    $model_name => [
369                        "version" => $wgOresModelVersions['models'][$model_name]['version'],
370                    ],
371                ],
372                "scores" => [
373                    $rev_id => [
374                        $model_name => [
375                            "error" => [
376                                "message" => $error_message,
377                                "type" => $error_type,
378                            ],
379                        ],
380                    ],
381                ],
382            ],
383        ];
384    }
385
386    /**
387     * @param array $response
388     * @return array[]
389     */
390    private function modifyRevertRiskResponse( array $response ): array {
391        return [
392            $response["wiki_db"] => [
393                "models" => [
394                    "revertrisklanguageagnostic" => [
395                        "version" => $response["model_version"]
396                    ]
397                ],
398                "scores" => [
399                    (string)$response["revision_id"] => [
400                        "revertrisklanguageagnostic" => [
401                            "score" => [
402                                "prediction" => $response["output"]["prediction"] ? "true" : "false",
403                                "probability" => [
404                                    "true" => $response["output"]["probabilities"]["true"],
405                                    "false" => $response["output"]["probabilities"]["false"]
406                                ]
407                            ]
408                        ]
409                    ]
410                ]
411            ]
412        ];
413    }
414
415}