mirror of
https://github.com/wassname/talk.git
synced 2026-09-09 11:38:08 +08:00
feat: added perspective feedback
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user