Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
2.34% |
5 / 214 |
|
15.38% |
2 / 13 |
CRAP | |
0.00% |
0 / 1 |
LiftWingService | |
2.34% |
5 / 214 |
|
15.38% |
2 / 13 |
2473.91 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getBaseUrl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFrontendBaseUrl | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getUrl | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
request | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
42 | |||
singleLiftWingRequest | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
110 | |||
revertRiskLiftWingRequest | |
0.00% |
0 / 54 |
|
0.00% |
0 / 1 |
240 | |||
prepareRevertriskRequest | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
createHostHeader | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
parseLiftWingResults | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
72 | |||
createRevisionNotFoundResponse | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
createRevisionNotScorableResponse | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
2 | |||
modifyRevertRiskResponse | |
0.00% |
0 / 22 |
|
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 | |
17 | namespace ORES; |
18 | |
19 | use MediaWiki\Config\Config; |
20 | use MediaWiki\Http\HttpRequestFactory; |
21 | use MediaWiki\Json\FormatJson; |
22 | use MediaWiki\MediaWikiServices; |
23 | use MediaWiki\Status\Status; |
24 | use Psr\Log\LoggerInterface; |
25 | use RuntimeException; |
26 | |
27 | /** |
28 | * Common methods for accessing a Lift Wing server. |
29 | */ |
30 | class 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 | } |