Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
2.86% |
5 / 175 |
|
16.67% |
2 / 12 |
CRAP | |
0.00% |
0 / 1 |
LiftWingService | |
2.86% |
5 / 175 |
|
16.67% |
2 / 12 |
1985.76 | |
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 / 39 |
|
0.00% |
0 / 1 |
110 | |||
revertRiskLiftWingRequest | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
132 | |||
prepareRevertriskRequest | |
0.00% |
0 / 9 |
|
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 | |||
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 FormatJson; |
20 | use MediaWiki\Config\Config; |
21 | use MediaWiki\Http\HttpRequestFactory; |
22 | use MediaWiki\Status\Status; |
23 | use Psr\Log\LoggerInterface; |
24 | use RuntimeException; |
25 | |
26 | /** |
27 | * Common methods for accessing a Lift Wing server. |
28 | */ |
29 | class 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 | } |