feat: added perspective feedback

This commit is contained in:
Wyatt Johnson
2018-11-06 12:39:16 -07:00
parent afb9fdcdc1
commit 9edf1d7c41
5 changed files with 193 additions and 41 deletions
@@ -27,3 +27,4 @@ Configuration:
be processed before it will skip the toxicity analysis, parsed by
[ms](https://www.npmjs.com/package/ms). (Default `300ms`)
- `TALK_PERSPECTIVE_DO_NOT_STORE` - Whether the API stores or deletes the comment text and context from this request after it has been evaluated. Stored comments will be used for future research and community model building purposes to improve the API over time. (Default `true`) [Perspective API - Analyze Comment Request](https://github.com/conversationai/perspectiveapi/blob/master/api_reference.md#analyzecomment-request)
- `TALK_PERSPECTIVE_SEND_FEEDBACK` - If set to `TRUE`, this plugin will send back moderation actions as feedback to [Perspective](http://perspectiveapi.com/) to improve their model. (Default `FALSE`)
@@ -1,13 +1,9 @@
{
"name": "@coralproject/talk-plugin-toxicity",
"pluginName": "talk-plugin-toxicity",
"name": "@coralproject/talk-plugin-toxic-comments",
"pluginName": "talk-plugin-toxic-comments",
"version": "0.0.1",
"description": "Provides support for measuring the toxicity of user comments using the Perspectives API",
"main": "index.js",
"author": "The Coral Project Team <coral@mozillafoundation.org>",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.0.1",
"ms": "^2.0.0"
}
"author": "The Coral Project Team <coralcore@mozillafoundation.org>",
"license": "Apache-2.0"
}
@@ -8,6 +8,7 @@ const config = {
THRESHOLD: process.env.TALK_TOXICITY_THRESHOLD || 0.8,
API_TIMEOUT: ms(process.env.TALK_PERSPECTIVE_TIMEOUT || '300ms'),
DO_NOT_STORE: process.env.TALK_PERSPECTIVE_DO_NOT_STORE || true,
SEND_FEEDBACK: process.env.TALK_PERSPECTIVE_SEND_FEEDBACK === 'TRUE',
};
if (process.env.NODE_ENV !== 'test' && !config.API_KEY) {
@@ -1,5 +1,8 @@
const { getScores, isToxic } = require('./perspective');
const { getScores, submitFeedback, isToxic } = require('./perspective');
const { SEND_FEEDBACK } = require('./config');
const { ErrToxic } = require('./errors');
const { merge } = require('lodash');
const debug = require('debug')('talk:plugin:toxic-comments');
function handlePositiveToxic(input) {
@@ -28,7 +31,8 @@ async function getScore(body) {
return scores;
}
module.exports = {
// Create all the hooks that will enable Perspective to add scores to Comments.
const hooks = {
RootMutation: {
editComment: {
pre: async (_, { edit: { body }, edit }) => {
@@ -59,3 +63,57 @@ module.exports = {
},
},
};
// If feedback sending is enabled, we need to add in the hooks for processing
// feedback.
if (SEND_FEEDBACK) {
// statusMap provides a map of Talk names to ones Perspective are expecting.
const statusMap = {
ACCEPTED: 'APPROVED',
REJECTED: 'DELETED',
};
// Merge these hooks into the hooks for plugging into the graph operations.
merge(hooks, {
RootMutation: {
// Hook into mutations associated with accepting/rejecting comments.
setCommentStatus: {
async post(root, args, ctx) {
if (ctx.user && args.status in statusMap) {
const comment = await ctx.loaders.Comments.get.load(args.id);
if (comment) {
const asset = await ctx.loaders.Assets.getByID.load(
comment.asset_id
);
// Submit feedback.
submitFeedback(comment, asset, statusMap[args.status]);
}
}
},
},
// Hook into mutations associated with featuring comments.
addTag: {
async post(root, args, ctx) {
if (
ctx.user &&
args.tag.name === 'FEATURED' &&
args.tag.item_type === 'COMMENTS'
) {
const comment = await ctx.loaders.Comments.get.load(args.tag.id);
if (comment) {
const asset = await ctx.loaders.Assets.getByID.load(
comment.asset_id
);
// Submit feedback.
submitFeedback(comment, asset, 'HIGHLIGHTED');
}
}
},
},
},
});
}
module.exports = hooks;
@@ -1,3 +1,4 @@
const { URL } = require('url');
const fetch = require('node-fetch');
const {
API_ENDPOINT,
@@ -8,6 +9,34 @@ const {
} = require('./config');
const debug = require('debug')('talk:plugin:toxic-comments');
// Load the global Talk configuration, we want to grab some variables..
const { ROOT_URL } = require('config');
// Use the ROOT_URL to grab the domain to construct a communityID for the
// feedback.
const communityId = `Coral:${new URL(ROOT_URL).domain}`;
async function send(method, body) {
// Perform the fetch.
const res = await fetch(`${API_ENDPOINT}/${method}?key=${API_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
timeout: API_TIMEOUT,
body: JSON.stringify(body, null, 2),
});
if (!res.ok) {
return null;
}
// Grab the JSON from the request.
const data = await res.json();
// Send the data back!
return data;
}
/**
* Get scores from the perspective api
*
@@ -17,33 +46,20 @@ const debug = require('debug')('talk:plugin:toxic-comments');
async function getScores(text) {
debug('Sending to Perspective: %o', text);
const response = await fetch(
`${API_ENDPOINT}/comments:analyze?key=${API_KEY}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
timeout: API_TIMEOUT,
body: JSON.stringify({
comment: {
text,
},
// TODO: support other languages.
languages: ['en'],
doNotStore: DO_NOT_STORE,
requestedAttributes: {
TOXICITY: {},
SEVERE_TOXICITY: {},
},
}),
}
);
const data = await response.json();
// If we get an error, just say it's not a toxic comment.
if (data.error) {
// Send the comment off to be analyzed.
const data = await send('comments:analyze', {
comment: {
text,
},
// TODO: support other languages.
languages: ['en'],
doNotStore: DO_NOT_STORE,
requestedAttributes: {
TOXICITY: {},
SEVERE_TOXICITY: {},
},
});
if (!data || data.error) {
debug('Received Error when submitting: %o', data.error);
return {
TOXICITY: {
@@ -90,6 +106,19 @@ function isToxic(scoresOrProbability) {
return probability > THRESHOLD;
}
/**
* wrapError will mask API key in error messages.
*
* @param {Error} err the error potentially containing the API key
*/
function wrapError(err) {
if (err.message) {
err.message = err.message.replace(API_KEY, '***');
}
return err;
}
/**
* maskKeyInError is a decorator that calls fn and masks the
* API_KEY in errors before throwing.
@@ -102,16 +131,83 @@ function maskKeyInError(fn) {
try {
return await fn(...args);
} catch (err) {
if (err.message) {
err.message = err.message.replace(API_KEY, '***');
}
throw err;
throw wrapError(err);
}
};
}
/**
* submitFeedback will send back moderation feedback to Perspective.
*
* @param {Object} comment the Comment that feedback is related to
* @param {Object} asset the Asset where the Comment was made on
* @param {Object} status the attribute to send back to Perspective
*/
const submitFeedback = (
{
id: Coral_comment_id, // Comment ID.
parent_id: reply_to_id_Coral_comment_id, // Comment parent id (reply parent).
body: text, // Comment body.
}, // Comment.
{
url, // Asset (article) URL.
}, // Asset (article).
status // Either APPROVED, DELETED, or HIGHLIGHTED.
) =>
// Handle this operation in the next tick, so it does not affect the current
// comment processing.
process.nextTick(async () => {
// Construct a client token.
const clientToken = `comment:${Coral_comment_id}`;
try {
// Send the feedback to perspective.
const body = await send('comments:suggestscore', {
comment: {
text,
},
context: {
entries: [
{
text: JSON.stringify({
url,
reply_to_id_Coral_comment_id,
Coral_comment_id,
}),
},
],
},
attributeScores: {
[status]: {
summaryScore: {
value: 1,
},
},
},
languages: ['EN'],
communityId,
clientToken,
});
if (!body || body.clientToken !== clientToken) {
throw new Error(
`"${JSON.stringify(
body
)}" did not contain the clientToken we expected`
);
}
debug(`sent ${status} feedback to perspective`);
} catch (err) {
console.error(
`could not send ${status} feedback to perspective`,
wrapError(err)
);
}
});
module.exports = {
getScores: maskKeyInError(getScores),
getProbability,
submitFeedback,
isToxic,
};