Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.86% covered (danger)
2.86%
5 / 175
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
LiftWingService
2.86% covered (danger)
2.86%
5 / 175
16.67% covered (danger)
16.67%
2 / 12
1985.76
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 / 39
0.00% covered (danger)
0.00%
0 / 1
110
 revertRiskLiftWingRequest
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
132
 prepareRevertriskRequest
0.00% covered (danger)
0.00%
0 / 9
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
 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 FormatJson;
20use MediaWiki\Config\Config;
21use MediaWiki\Http\HttpRequestFactory;
22use MediaWiki\Status\Status;
23use Psr\Log\LoggerInterface;
24use RuntimeException;
25
26/**
27 * Common methods for accessing a Lift Wing server.
28 */
29class LiftWingService extends ORESService {
30
31    public const API_VERSION = 1;
32    private Config $config;
33
34    public function __construct(
35        LoggerInterface $logger, HttpRequestFactory $httpRequestFactory, Config $config
36    ) {
37        parent::__construct( $logger, $httpRequestFactory );
38        $this->config = $config;
39    }
40
41    /**
42     * @return string Base URL of ORES service
43     */
44    public static function getBaseUrl() {
45        global $wgOresLiftWingBaseUrl;
46
47        return $wgOresLiftWingBaseUrl;
48    }
49
50    /**
51     * @return string Base URL of ORES service being used externally
52     */
53    public static function getFrontendBaseUrl() {
54        global $wgOresFrontendBaseUrl, $wgOresLiftWingBaseUrl;
55
56        if ( $wgOresFrontendBaseUrl === null ) {
57            return $wgOresLiftWingBaseUrl;
58        }
59
60        return $wgOresFrontendBaseUrl;
61    }
62
63    /**
64     * @param string|null $model
65     * @return string Base URL plus your wiki's `scores` API path.
66     */
67    public function getUrl( $model = null ) {
68        $wikiId = self::getWikiID();
69        $prefix = 'v' . self::API_VERSION;
70        $baseUrl = self::getBaseUrl();
71        return "{$baseUrl}{$prefix}/models/{$wikiId}-{$model}:predict";
72    }
73
74    /**
75     * Make an ORES API request and return the decoded result.
76     *
77     * @param array $params
78     * @param array|null $originalRequest
79     *
80     * @return array Decoded response
81     */
82    public function request( array $params, $originalRequest = null ) {
83        if ( !isset( $params['models'] ) ) {
84            throw new RuntimeException( 'Missing required parameter: models' );
85        }
86        if ( !isset( $params['revids'] ) ) {
87            throw new RuntimeException( 'Missing required parameter: revids' );
88        }
89
90        $models = explode( '|', $params['models'] );
91        $revids = explode( '|', $params['revids'] );
92
93        $responses = [];
94
95        foreach ( $models as $model ) {
96            foreach ( $revids as $revid ) {
97                if ( $model == 'revertrisklanguageagnostic' ) {
98                    // This is a hack to convert the revert-risk response to an ORES response in order to be compatible
99                    // with the rest of the model responses in order for them to be easily merged together in one
100                    // response.
101                    $response = $this->revertRiskLiftWingRequest( $model, $revid );
102                } else {
103                    $response = $this->singleLiftWingRequest( $model, $revid );
104                }
105
106                $responses[] = $response;
107            }
108        }
109        $wikiId = self::getWikiID();
110        return $this->parseLiftWingResults( $wikiId, $responses );
111    }
112
113    /**
114     * Make a single call to LW for one revid and one model and return the decoded result.
115     *
116     * @param string $model
117     * @param string $revid
118     *
119     * @return array Decoded response
120     */
121    public function singleLiftWingRequest( $model, $revid ) {
122        $url = $this->getUrl( $model );
123        $this->logger->debug( "Requesting: {$url}" );
124
125        $req = $this->httpRequestFactory->create( $url, [
126            'method' => 'POST',
127            'postData' => json_encode( [ 'rev_id' => (int)$revid ] ),
128            ],
129        );
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                ],
146                );
147                if ( $wgOresLiftWingAddHostHeader ) {
148                    $req->setHeader( 'Content-Type', 'application/json' );
149                    $req->setHeader( 'Host', self::createHostHeader( $model ) );
150                }
151
152                $status = $req->execute();
153                if ( !$status->isOK() ) {
154                    throw new RuntimeException( $message );
155                }
156            } elseif ( $req->getStatus() === 400 ) {
157                $this->logger->debug( "400 Bad Request: {$message}" );
158                $data = FormatJson::decode( $req->getContent(), true );
159                if ( strpos( $data["error"], "The MW API does not have any info related to the rev-id" ) === 0 ) {
160                    return $this->createRevisionNotFoundResponse( $model, $revid );
161                } else {
162                    throw new RuntimeException( $message );
163                }
164            } else {
165                throw new RuntimeException( $message );
166            }
167        }
168        $json = $req->getContent();
169        $this->logger->debug( "Raw response: {$json}" );
170        $data = FormatJson::decode( $json, true );
171        if ( !$data || !empty( $data['error'] ) ) {
172            throw new RuntimeException( "Bad response from Lift Wing endpoint [{$url}]: {$json}" );
173        }
174        return $data;
175    }
176
177    /**
178     * @param string $model
179     * @param string|int $revid
180     * @return array|array[]|mixed
181     */
182    public function revertRiskLiftWingRequest( $model, $revid ) {
183        $wikiId = self::getWikiID();
184        $language = substr( $wikiId, 0, strpos( $wikiId, "wiki" ) );
185        $prefix = 'v' . self::API_VERSION;
186        $baseUrl = self::getBaseUrl();
187        $url = "{$baseUrl}{$prefix}/models/revertrisk-language-agnostic:predict";
188
189        $this->logger->debug( "Requesting: {$url}" );
190
191        $req = $this->prepareRevertriskRequest( $url, $revid, $language );
192        $status = $req->execute();
193        if ( !$status->isOK() ) {
194            $message = "Failed to make LiftWing request to [{$url}], " .
195                Status::wrap( $status )->getMessage()->inLanguage( 'en' )->text();
196
197            // Server time out, try again once
198            if ( $req->getStatus() === 504 ) {
199                $req = $this->prepareRevertriskRequest( $url, $revid, $language );
200                $status = $req->execute();
201                if ( !$status->isOK() ) {
202                    throw new RuntimeException( $message );
203                }
204            } elseif ( $req->getStatus() === 400 ) {
205                $this->logger->debug( "400 Bad Request: {$message}" );
206                $data = FormatJson::decode( $req->getContent(), true );
207                // This is a way to detect whether an error occurred because the revision was not found.
208                // Checking for "detail" key in the response is a hack to detect the error message from Lift Wing as
209                // there is no universal error handling/messaging at the moment.
210                if ( ( $data && isset( $data['error'] ) && strpos( $data["error"],
211                        "The MW API does not have any info related to the rev-id" ) === 0 ) ||
212                    array_key_exists( "detail", $data ) ) {
213                    return $this->createRevisionNotFoundResponse(
214                        $model,
215                        $revid
216                    );
217                } else {
218                    throw new RuntimeException( $message );
219                }
220            } else {
221                throw new RuntimeException( $message );
222            }
223        }
224        $json = $req->getContent();
225        $this->logger->debug( "Raw response: {$json}" );
226        $data = FormatJson::decode( $json, true );
227        if ( !$data || !empty( $data['error'] ) ) {
228            throw new RuntimeException( "Bad response from Lift Wing endpoint [{$url}]: {$json}" );
229        }
230        return $this->modifyRevertRiskResponse( $data );
231    }
232
233    private function prepareRevertriskRequest( string $url, string $revid, string $language ): \MWHttpRequest {
234        $req = $this->httpRequestFactory->create( $url, [
235            'method' => 'POST',
236            'postData' => json_encode( [ 'rev_id' => (int)$revid, 'lang' => $language ] ),
237        ],
238        );
239        $req->setHeader( 'Content-Type', 'application/json' );
240        global $wgOresLiftWingAddHostHeader;
241        if ( $wgOresLiftWingAddHostHeader ) {
242            $req->setHeader( 'Host', $this->config->get( 'OresLiftWingRevertRiskHostHeader' ) );
243        }
244        return $req;
245    }
246
247    /**
248     * @param string $modelname the model name as requested from ORES
249     * @return string The hostname required in the header for the Lift Wing call
250     */
251    public static function createHostHeader( string $modelname ) {
252        $hostnames = [
253            'articlequality' => 'revscoring-articlequality',
254            'itemquality' => 'revscoring-articlequality',
255            'articletopic' => 'revscoring-articletopic',
256            'itemtopic' => 'revscoring-articletopic',
257            'draftquality' => 'revscoring-draftquality',
258            'drafttopic' => 'revscoring-drafttopic',
259            'damaging' => 'revscoring-editquality-damaging',
260            'goodfaith' => 'revscoring-editquality-goodfaith',
261            'reverted' => 'revscoring-editquality-reverted',
262        ];
263        $wikiID = self::getWikiID();
264        return "{$wikiID}-{$modelname}.{$hostnames[$modelname]}.wikimedia.org";
265    }
266
267    /**
268     * This function merges the multiple Lift Wing responses into the appropriate format as returned from ORES
269     * @param string $context
270     * @param array $responses
271     * @return array
272     */
273    private function parseLiftWingResults( string $context, array $responses ): array {
274        $result = [];
275        foreach ( $responses as $d ) {
276            if ( !$d ) {
277                continue;
278            }
279            foreach ( $d[$context] as $k => $v ) {
280                if ( is_array( $v ) && $k === "scores" ) {
281                    foreach ( $v as $rev_id => $scores ) {
282                        if ( isset( $result[$context][$k][$rev_id] ) ) {
283                            $result[$context][$k][$rev_id] = array_merge( $result[$context][$k][$rev_id], $scores );
284                        } else {
285                            $result[$context][$k][$rev_id] = $scores;
286                        }
287                    }
288                } else {
289                    $result[$context][$k] = array_merge( $result[$context][$k] ?? [], $v );
290                }
291            }
292        }
293        return $result;
294    }
295
296    /**
297     * @param string $model_name
298     * @param string $rev_id
299     * @return array
300     */
301    private function createRevisionNotFoundResponse(
302        string $model_name,
303        string $rev_id
304    ) {
305        global $wgOresModelVersions;
306        $error_message = "RevisionNotFound: Could not find revision ({revision}:{$rev_id})";
307        $error_type = "RevisionNotFound";
308        return [
309            self::getWikiID() => [
310                "models" => [
311                    $model_name => [
312                        "version" => $wgOresModelVersions['models'][$model_name]['version'],
313                    ],
314                ],
315                "scores" => [
316                    $rev_id => [
317                        $model_name => [
318                            "error" => [
319                                "message" => $error_message,
320                                "type" => $error_type,
321                            ],
322                        ],
323                    ],
324                ],
325            ],
326        ];
327    }
328
329    /**
330     * @param array $response
331     * @return array[]
332     */
333    private function modifyRevertRiskResponse( array $response ): array {
334        return [
335            $response["wiki_db"] => [
336                "models" => [
337                    "revertrisklanguageagnostic" => [
338                        "version" => $response["model_version"]
339                    ]
340                ],
341                "scores" => [
342                    (string)$response["revision_id"] => [
343                        "revertrisklanguageagnostic" => [
344                            "score" => [
345                                "prediction" => $response["output"]["prediction"] ? "true" : "false",
346                                "probability" => [
347                                    "true" => $response["output"]["probabilities"]["true"],
348                                    "false" => $response["output"]["probabilities"]["false"]
349                                ]
350                            ]
351                        ]
352                    ]
353                ]
354            ]
355        ];
356    }
357
358}