User:He7d3r/Tools/DraftAndArticleQualityCore.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
- Opera: Press Ctrl-F5.
mw.loader.using(['mediawiki.api']).done(
(function($, mw){
var defaultOptions = {
parameters: {
format: 'json'
},
ajax: {
timeout: 30 * 1000, // 30 seconds
dataType: 'json',
type: 'GET'
},
score: {
batchSize: 50,
maxWorkers: 4
}
};
/**
* Constructor to create a pool of workers to request a set of model-scores
* from the ORES api. This object will allow scoring jobs to be
* transparently started within a worker pool. The worker pool will then
* manage all requests in order to comply with the options provided
* (e.g. batchSize and maxWorkers).
*
* var ores = require('ext.ores.api');
* aqPool = ores.Pool([ "articlequality" ], {batchSize: 50, maxWorkers: 3})
*
* aqPool.score(214412)
* .done(function(scoreDoc){...})
* aqPool.score(214413)
* .done(function(scoreDoc){...})
*
* @constructor
* @param {Object} [oresApi] An OresApi object to use for querying
* @param {Array} [models] A list of models to query for in each request
* @param {Object} [options] A set of options for querying. See defaultOptions.score above
*/
var OresScoreBatcherPool = function(oresApi, models, options) {
this.oresApi = oresApi;
this.models = models;
this.batchSize = options.batchSize || defaultOptions.score.batchSize;
this.maxWorkers = options.maxWorkers || defaultOptions.score.maxWorkers;
this.liveWorkers = 0;
this.taskQueue = [];
this.tasksQueued = $.Deferred()
.progress(this.ensureWorkers.bind(this));
};
OresScoreBatcherPool.prototype = {
/**
* Add a score job to the queue and return a promise for the result.
*
* @param {number} [revId] A revision to score with the given model
*/
score: function(revId) {
var resultDfd = $.Deferred(),
task = {revId: revId, resultDfd: resultDfd};
this.taskQueue.push(task);
this.tasksQueued.notify();
return resultDfd.promise();
},
/**
* Process a batch and recurse until the taskQueue is empty
*/
processTaskBatches: function(){
// Get a batch to process
batch = this.taskQueue.splice(0, this.batchSize);
// If there's stuff to process, start a new score batch processing job and
// recurse when it finishes.
if (batch.length) {
this.scoreBatch(batch)
.fail(function(){
for (var i = 0; i < batch.length; i++) {
batch[i].resultDfd.error.apply(null, arguments);
}
})
.always(this.processTaskBatches.bind(this));
} else {
// shut down worker and decrement the worker count
this.liveWorkers -= 1;
console.debug("Shutting down worker");
}
},
/**
* Generate a set of scores for a batch. Results will be sent to specific
* deferred result objects.
*/
scoreBatch: function(batch){
var batchDfd = $.Deferred(),
revIds = [];
for (var i = 0; i < batch.length; i++) {
revIds.push(batch[i].revId);
}
this.oresApi.get({revids: revIds, models: this.models})
.done(function(responseDoc){
for (var i = 0; i < batch.length; i++) {
var scoreDoc = responseDoc[this.oresApi.options.dbname].scores[batch[i].revId];
batch[i].resultDfd.resolve(scoreDoc);
}
}.bind(this))
.fail(function(){
for (var i = 0; i < batch.length; i++) {
batch[i].resultDfd.error.apply(null, arguments);
}
})
.always(function(){batchDfd.resolve()});
return batchDfd.promise();
},
/**
* Ensure that workers are running. This method is used when a task is
* added to the queue to make sure that the workers are started/restarted
* if necessary. Note that a 50ms delay is implemented for starting a
* worker process to ensure that tasks are given enough time to enqueue
* before batch processing starts.
*/
ensureWorkers: function(){
while (this.liveWorkers < this.maxWorkers) {
console.debug("Starting up worker");
setTimeout(
function(){this.processTaskBatches()}.bind(this),
50);
this.liveWorkers += 1;
}
}
};
/**
* Constructor to create an object to interact with the API of an ORES server.
* OresApi objects represent the API of a particular ORES server.
*
* var ores = require('ext.ores.api');
* ores.get( {
* revids: [1234, 1235],
* models: [ 'damaging', 'articlequality' ] // same effect as 'damaging|articlequality'
* } ).done( function ( data ) {
* console.log( data );
* } );
*
* @constructor
* @param {Object} [options] See #defaultOptions documentation above.
*/
var OresApi = function ( options ) {
options.parameters = $.extend( {}, defaultOptions.parameters, options.parameters );
options.ajax = $.extend( {}, defaultOptions.ajax, options.ajax );
options.score = $.extend( {}, defaultOptions.score, options.score );
if ( options.ajax.url ) {
options.ajax.url = String( options.ajax.url );
} else {
options.ajax.url = options.host + '/v3/scores/' + options.dbname;
}
this.options = options;
this.requests = [];
};
OresApi.prototype = {
/**
* Abort all unfinished requests issued by this Api object.
*
* @method
*/
abort: function () {
this.requests.forEach( function ( request ) {
if ( request ) {
request.abort();
}
} );
},
/**
* Massage parameters from the nice format we accept into a format suitable for the API.
*
* @private
* @param {Object} parameters (modified in-place)
*/
preprocessParameters: function ( parameters ) {
var key;
for ( key in parameters ) {
if ( Array.isArray( parameters[ key ] ) ) {
parameters[ key ] = parameters[ key ].join( '|' );
} else if ( parameters[ key ] === false || parameters[ key ] === undefined ) {
// Boolean values are only false when not given at all
delete parameters[ key ];
}
}
},
/**
* Perform an API call.
*
* @param {Object} parameters
* @return {jQuery.Promise} Done: API response data and the jqXHR object.
* Fail: Error code
*/
get: function ( parameters ) {
var requestIndex,
api = this,
apiDeferred = $.Deferred(),
xhr,
ajaxOptions;
parameters = $.extend( {}, this.options.parameters, parameters );
ajaxOptions = $.extend( {}, this.options.ajax );
this.preprocessParameters( parameters );
ajaxOptions.data = $.param( parameters );
xhr = $.ajax( ajaxOptions )
.done( function ( result, textStatus, jqXHR ) {
var code;
if ( result.error ) {
code = result.error.code === undefined ? 'unknown' : result.error.code;
apiDeferred.reject( code, result, jqXHR );
} else {
apiDeferred.resolve( result, jqXHR );
}
} );
requestIndex = this.requests.length;
this.requests.push( xhr );
xhr.always( function () {
api.requests[ requestIndex ] = null;
} );
return apiDeferred.promise( { abort: xhr.abort } ).fail( function ( code, details ) {
if ( !( code === 'http' && details && details.textStatus === 'abort' ) ) {
mw.log( 'OresApi error: ', code, details );
}
} );
},
/**
* Create a new OresScoreBatcherPool using this api session.
*
* @param {Array} [models] A list of models to query for in each request
* @param {Object} [options] A set of options for querying. See defaultOptions.score above
*/
pool: function(models, options){
return new OresScoreBatcherPool(this, models, options);
}
};
var ArticleQuality = function(options){
this.weights = options.weights;
this.names = options.names;
this.assessment_system = options.assessment_system;
this.modelName = options.modelName || "articlequality";
this.weightedSumPlaceholder = options.weightedSumPlaceholder;
this.mwApi = new mw.Api();
this.oresHost = options.ores_host;
this.oresDbname = options.dbname;
this.oresApi = new OresApi({host: options.ores_host, dbname: options.dbname});
this.aqPool = this.oresApi.pool(this.modelName, {batchSize: 10});
};
ArticleQuality.prototype = {
computeWeightedSum: function(score){
var clsProba = score.probability;
var weightedSum = 0;
for (var cls in clsProba) {
if (clsProba.hasOwnProperty(cls)) {
var proba = clsProba[cls];
weightedSum += proba * this.weights[cls];
}
}
return weightedSum;
},
computeWeightedProportion: function(score){
var weightedSum = this.computeWeightedSum(score);
return weightedSum / Math.max.apply(null, Object.values(this.weights));
},
extractPrediction: function(score){
return score.prediction;
},
parseText: function(text){
var dfd = jQuery.Deferred();
this.mwApi.get({action: "parse", text: text, contentmodel: "wikitext", formatversion: 2, prop: "text", disablelimitreport: true})
.done(function(data){dfd.resolve($(data.parse.text).find('p').html())})
.fail(function(error){dfd.reject(error)});
return dfd.promise();
},
getCurrentRevId: function(title){
var dfd = jQuery.Deferred();
this.mwApi.get({action: 'query', prop: 'revisions', titles: title, rvprop: 'ids', formatversion: 2})
.done(function(data){
if (data.query.pages[0].missing) {
dfd.reject('Missing page: ' + data.query.pages[0].title);
} else {
dfd.resolve(data.query.pages[0].revisions[0].revid);
}
})
.fail(function(error){dfd.reject(error)});
return dfd.promise();
},
oresScore: function(revId){
var dfd = $.Deferred();
this.aqPool.score(revId)
.done(function(scoreDoc){dfd.resolve(scoreDoc[this.modelName].score, revId)}.bind(this))
.fail(function(){dfd.error.apply(null, arguments)});
return dfd.promise();
},
getAndRenderScoreHeader: function(){
var revId = mw.config.get('wgRevisionId');
this.oresScore(revId)
.done(this.renderScoreHeader.bind(this))
.fail(function(error){console.error(error)});
},
renderScoreHeader: function(score, revId){
var rawText = this.formatScoreHeader(score, revId);
var v2022 = document.body.classList.contains( 'skin-vector-2022' );
var qualityBlock = $('<div>').addClass("article_quality");
if ( v2022 ) {
qualityBlock.addClass( 'mw-indicator' );
$('.mw-indicators').prepend(qualityBlock);
} else {
$('#bodyContent').prepend(qualityBlock);
}
this.parseText(rawText)
.done(function(html){qualityBlock.html(html)})
.fail(function(error){console.error(error)});
},
formatScoreHeader: function(score, revId){
var prediction = this.extractPrediction(score);
if(!this.weightedSumPlaceholder){
var weightedSum = this.computeWeightedSum(score);
var roundedWeightedSum = Math.round(weightedSum*100)/100;
var localizedWeightedSum = roundedWeightedSum.toLocaleString(
mw.config.get("wgULSAcceptLanguageList", window.navigator.languages || [])[0]);
}else{
var localizedWeightedSum = this.weightedSumPlaceholder;
}
var anchoredWeightedSum = '[' + this.oresHost + "/v3/scores/" +
this.oresDbname + "/" + revId + "/articlequality " +
localizedWeightedSum + "]"
return this.assessment_system + ": " +
this.names[prediction] + " (" +
anchoredWeightedSum + ")";
},
renderScoreLink: function(score, span){
var prediction = this.extractPrediction(score);
this.parseText(this.names[prediction])
.done(function(html){span.prepend(html)})
.fail(function(error){console.error(error)});
},
getAndRenderScoreLink: function(revId, span){
this.oresScore(revId)
.done(function(score){this.renderScoreLink(score, span)}.bind(this))
.fail(function(error){console.error(error)});
},
addScoresToArticleLinks: function(){
$("span.ores-wp10-prediction, span.ores-quality-prediction").each(function(i, element){
var span = $(element);
var anchor = span.find('a');
var pageTitle = anchor.attr('title');
this.getCurrentRevId(pageTitle)
.done(function(revId){this.getAndRenderScoreLink(revId, span)}.bind(this))
.fail(function(error){console.error(error)});
}.bind(this));
},
renderHistoryScore: function(li, score){
var level = Math.round(this.computeWeightedSum(score));
var weightedProportion = this.computeWeightedProportion(score);
var qualityPredictionNode = $("<div>").addClass("qualityprediction")
.addClass("level_" + level)
.append($("<div>").addClass("bar").css("width", Math.round(weightedProportion*100) + "%").append(" "))
.attr('title', this.formatScoreHeader(score, li.attr('data-mw-revid')));
li.prepend(qualityPredictionNode);
},
getAndRenderHistoryScore: function(li){
var revId = li.attr('data-mw-revid');
this.oresScore(revId)
.done(function(score){this.renderHistoryScore(li, score)}.bind(this))
.fail(function(error){console.error(error)});
},
getAndRenderHistoryScores: function(){
var revisionNodes = $('#pagehistory li');
revisionNodes.each(function(i, element){this.getAndRenderHistoryScore($(element))}.bind(this));
}
}
var DraftQuality = function(options){
// this.weights = options.weights;
this.names = options.names;
this.assessment_system = options.assessment_system;
this.modelName = options.modelName || "draftquality";
this.probaPlaceholder = options.probaPlaceholder
// this.mwApi = new mw.Api();
this.oresHost = options.ores_host
this.oresDbname = options.dbname
this.oresApi = new OresApi({host: options.ores_host, dbname: options.dbname});
this.dqPool = this.oresApi.pool(this.modelName, {batchSize: 10});
};
DraftQuality.prototype = {
// computeWeightedSum: function(score){
// var clsProba = score.probability;
// var weightedSum = 0;
// for (var cls in clsProba) {
// if (clsProba.hasOwnProperty(cls)) {
// var proba = clsProba[cls];
// weightedSum += proba * this.weights[cls];
// }
// }
// return weightedSum;
// },
// computeWeightedProportion: function(score){
// var weightedSum = this.computeWeightedSum(score);
// return weightedSum / Math.max.apply(null, Object.values(this.weights));
// },
extractPrediction: function(score){
return score.prediction;
},
extractProbability: function(score){
return score.probability[score.prediction];
},
// parseText: function(text){
// var dfd = jQuery.Deferred();
// this.mwApi.get({action: "parse", text: text, contentmodel: "wikitext", formatversion: 2, prop: "text", disablelimitreport: true})
// .done(function(data){dfd.resolve($(data.parse.text).find('p').html())})
// .fail(function(error){dfd.reject(error)});
// return dfd.promise();
// },
// getCurrentRevId: function(title){
// var dfd = jQuery.Deferred();
// this.mwApi.get({action: 'query', prop: 'revisions', titles: title, rvprop: 'ids', formatversion: 2})
// .done(function(data){dfd.resolve(data.query.pages[0].revisions[0].revid)})
// .fail(function(error){dfd.reject(error)});
// return dfd.promise();
// },
oresScore: function(revId){
var dfd = $.Deferred();
this.dqPool.score(revId)
.done(function(scoreDoc){dfd.resolve(scoreDoc[this.modelName].score, revId)}.bind(this))
.fail(function(){dfd.error.apply(null, arguments)});
return dfd.promise();
},
// getAndRenderScoreHeader: function(){
// var revId = mw.config.get('wgRevisionId');
// this.oresScore(revId)
// .done(this.renderScoreHeader.bind(this))
// .fail(function(error){console.error(error)});
// },
renderScoreHeader: function(score, revId){
var rawText = this.formatScoreHeader(score, revId);
var v2022 = document.body.classList.contains( 'skin-vector-2022' );
var qualityBlock = $('<div>').addClass("draft_quality");
if ( v2022 ) {
qualityBlock.addClass( 'mw-indicator' );
$('.mw-indicators').prepend(qualityBlock);
} else {
$('#bodyContent').prepend(qualityBlock);
}
this.parseText(rawText)
.done(function(html){qualityBlock.html(html)})
.fail(function(error){console.error(error)});
},
formatScoreHeader: function(score, revId){
var prediction = this.extractPrediction(score);
if(!this.probaPlaceholder){
var proba = this.extractProbability(score);
var roundedProba = Math.round(proba*100)/100;
var localizedProba = roundedProba.toLocaleString(
mw.config.get("wgULSAcceptLanguageList", window.navigator.languages || [])[0]);
}else{
var localizedProba = this.probaPlaceholder;
}
var anchoredProba = '[' + this.oresHost + "/v3/scores/" +
this.oresDbname + "/" + revId + "/draftquality " +
localizedProba + "]"
return this.assessment_system + ": " +
this.names[prediction] + " (" +
anchoredProba + ")";
},
// renderScoreLink: function(score, span){
// var prediction = this.extractPrediction(score);
// this.parseText(this.names[prediction])
// .done(function(html){span.prepend(html)})
// .fail(function(error){console.error(error)});
// },
// getAndRenderScoreLink: function(revId, span){
// this.oresScore(revId)
// .done(function(score){this.renderScoreLink(score, span)}.bind(this))
// .fail(function(error){console.error(error)});
// },
// addScoresToArticleLinks: function(){
// $("span.ores-wp10-prediction, span.ores-quality-prediction").each(function(i, element){
// var span = $(element);
// var anchor = span.find('a');
// var pageTitle = anchor.attr('title');
// this.getCurrentRevId(pageTitle)
// .done(function(revId){this.getAndRenderScoreLink(revId, span)}.bind(this))
// .fail(function(error){console.error(error)});
// }.bind(this));
// },
renderNewPageScore: function(li, score){
// var level = Math.round(this.computeWeightedSum(score));
// var weightedProportion = this.computeWeightedProportion(score);
// var qualityPredictionNode = $("<div>").addClass("qualityprediction")
// .addClass("level_" + level)
// .append($("<div>").addClass("bar").css("width", Math.round(weightedProportion*100) + "%").append(" "))
// .attr('title', this.formatScoreHeader(score));
// li.prepend(qualityPredictionNode);
li.prepend(
$("<div>").addClass("draftprediction")
.addClass("class_" + score.prediction)
.attr('title', this.formatScoreHeader(score))
);
},
getAndRenderNewPageScore: function(li){
var revId = li.attr('data-mw-revid');
this.oresScore(revId)
.done(function(score){this.renderNewPageScore(li, score)}.bind(this))
.fail(function(error){console.error(error)});
},
getAndRenderNewPageScores: function(){
var pageNodes = $('#mw-content-text li[data-mw-revid]');
pageNodes.each(function(i, element){this.getAndRenderNewPageScore($(element))}.bind(this));
}
}
window.DraftQuality = DraftQuality;
window.ArticleQuality = ArticleQuality;
})(jQuery, mediaWiki)
);