mirror of
https://github.com/wassname/talk.git
synced 2026-07-04 05:02:40 +08:00
Q&A Fixes (#2866)
* Fix various Q&A moderation/tagging issues - Allow Staff members to have their questions answered and appropriately tagged - Properly filter answering for only top level questions (comments) - Add documentation around various moderation phases and comment creation steps - Remove unnecessary status filter when setting the status for a comment * fix: abstracted out perspective configs * fix: reworked tag injection * Fix sorting/unused imports Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export * from "./notifier";
|
||||
export * from "./perspective";
|
||||
export * from "./slack";
|
||||
export * from "./subscription";
|
||||
export * from "./webhook";
|
||||
@@ -0,0 +1,121 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import { sendToPerspective } from "coral-server/services/perspective";
|
||||
|
||||
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { CommentStatusUpdatedCoralEventPayload } from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
type PerspectiveCoralEventListenerPayloads = CommentStatusUpdatedCoralEventPayload;
|
||||
|
||||
export class PerspectiveCoralEventListener
|
||||
implements CoralEventListener<PerspectiveCoralEventListenerPayloads> {
|
||||
public readonly name = "perspective";
|
||||
public readonly events = [CoralEventType.COMMENT_STATUS_UPDATED];
|
||||
|
||||
private computeStatus(status: GQLCOMMENT_STATUS) {
|
||||
if (status === GQLCOMMENT_STATUS.APPROVED) {
|
||||
return "APPROVED";
|
||||
}
|
||||
if (status === GQLCOMMENT_STATUS.REJECTED) {
|
||||
return "DELETED";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
PerspectiveCoralEventListenerPayloads
|
||||
> = ctx => async ({ data: { newStatus, commentID, commentRevisionID } }) => {
|
||||
const {
|
||||
tenant: {
|
||||
integrations: { perspective },
|
||||
},
|
||||
} = ctx;
|
||||
|
||||
if (
|
||||
// If perspective isn't enabled,
|
||||
!perspective.enabled ||
|
||||
// Or the key isn't provided
|
||||
!perspective.key ||
|
||||
// Or the feedback sending option is disabled
|
||||
!perspective.sendFeedback
|
||||
) {
|
||||
// Exit out then.
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the status being sent to perspective.
|
||||
const status = this.computeStatus(newStatus);
|
||||
if (!status) {
|
||||
// The new status was not associated with
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the comment in question.
|
||||
const comment = await ctx.loaders.Comments.comment.load(commentID);
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the target revision.
|
||||
const revision = comment.revisions.find(r => r.id === commentRevisionID);
|
||||
if (!revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the story for this comment.
|
||||
const story = await ctx.loaders.Stories.story.load(comment.storyID);
|
||||
if (!story) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruct the Tenant URL.
|
||||
const tenantURL = reconstructTenantURL(ctx.config, ctx.tenant);
|
||||
|
||||
// This typecast is needed because the custom `ms` format does not return the
|
||||
// desired `number` type even though that's the only type it can output.
|
||||
const timeout = (ctx.config.get(
|
||||
"perspective_timeout"
|
||||
) as unknown) as number;
|
||||
|
||||
// Get the response from perspective.
|
||||
const result = await sendToPerspective(
|
||||
{ key: perspective.key, endpoint: perspective.endpoint, timeout },
|
||||
{
|
||||
operation: "comments:suggestscore",
|
||||
locale: ctx.tenant.locale,
|
||||
body: {
|
||||
text: striptags(revision.body),
|
||||
commentID: comment.id,
|
||||
commentParentID: comment.parentID,
|
||||
commentStatus: status,
|
||||
storyURL: story.url,
|
||||
tenantURL,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
ctx.logger.debug(
|
||||
{ commentID: comment.id, commentRevisionID },
|
||||
"successfully sent moderation feedback to perspective"
|
||||
);
|
||||
} else {
|
||||
ctx.logger.error(
|
||||
{ status: result.status },
|
||||
"unable to send moderation feedback to perspective"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger.error(
|
||||
{ err },
|
||||
"unable to send moderation feedback to perspective"
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
approveComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
input.commentID,
|
||||
@@ -23,7 +22,6 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
rejectComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
input.commentID,
|
||||
|
||||
@@ -173,7 +173,6 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
? approveComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
commentID,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload {
|
||||
oldStatus: GQLCOMMENT_STATUS;
|
||||
moderatorID: string | null;
|
||||
commentID: string;
|
||||
commentRevisionID: string;
|
||||
}
|
||||
|
||||
export type CommentStatusUpdatedSubscription = SubscriptionType<
|
||||
|
||||
@@ -37,10 +37,13 @@ import {
|
||||
} from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
|
||||
import { NotifierCoralEventListener } from "./events/listeners/notifier";
|
||||
import { SlackCoralEventListener } from "./events/listeners/slack";
|
||||
import { SubscriptionCoralEventListener } from "./events/listeners/subscription";
|
||||
import { WebhookCoralEventListener } from "./events/listeners/webhook";
|
||||
import {
|
||||
NotifierCoralEventListener,
|
||||
PerspectiveCoralEventListener,
|
||||
SlackCoralEventListener,
|
||||
SubscriptionCoralEventListener,
|
||||
WebhookCoralEventListener,
|
||||
} from "./events/listeners";
|
||||
import CoralEventListenerBroker from "./events/publisher";
|
||||
import { isInstalled } from "./services/tenant";
|
||||
|
||||
@@ -219,6 +222,7 @@ class Server {
|
||||
this.broker.register(new SlackCoralEventListener());
|
||||
this.broker.register(new SubscriptionCoralEventListener());
|
||||
this.broker.register(new WebhookCoralEventListener(this.tasks.webhook));
|
||||
this.broker.register(new PerspectiveCoralEventListener());
|
||||
|
||||
// Setup the metrics collectors.
|
||||
collectDefaultMetrics({ timeout: 5000 });
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function createComment(
|
||||
const { body, actionCounts = {}, metadata, ...rest } = input;
|
||||
|
||||
// Generate the revision.
|
||||
const revision: Revision = {
|
||||
const revision: Readonly<Revision> = {
|
||||
id: uuid.v4(),
|
||||
body,
|
||||
actionCounts,
|
||||
@@ -184,7 +184,7 @@ export async function createComment(
|
||||
// Insert it into the database.
|
||||
await collection(mongo).insertOne(comment);
|
||||
|
||||
return comment;
|
||||
return { comment, revision };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,9 +734,6 @@ export async function updateCommentStatus(
|
||||
id,
|
||||
tenantID,
|
||||
"revisions.id": revisionID,
|
||||
status: {
|
||||
$ne: status,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: { status },
|
||||
|
||||
@@ -2,7 +2,6 @@ import Queue, { Job } from "bull";
|
||||
import { Db } from "mongodb";
|
||||
import now from "performance-now";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
Comment,
|
||||
@@ -22,7 +21,6 @@ const JOB_NAME = "rejector";
|
||||
export interface RejectorProcessorOptions {
|
||||
mongo: Db;
|
||||
redis: AugmentedRedis;
|
||||
config: Config;
|
||||
tenantCache: TenantCache;
|
||||
}
|
||||
|
||||
@@ -49,7 +47,6 @@ const createJobProcessor = ({
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
config,
|
||||
}: RejectorProcessorOptions) => async (job: Job<RejectorData>) => {
|
||||
// Pull out the job data.
|
||||
const { authorID, moderatorID, tenantID } = job.data;
|
||||
@@ -85,7 +82,6 @@ const createJobProcessor = ({
|
||||
await rejectComment(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
null,
|
||||
tenant,
|
||||
comment.id,
|
||||
|
||||
@@ -120,6 +120,7 @@ async function addCommentAction(
|
||||
...counts,
|
||||
before: oldComment,
|
||||
after: updatedComment,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
@@ -195,6 +196,7 @@ export async function removeCommentAction(
|
||||
...counts,
|
||||
before: oldComment,
|
||||
after: updatedComment,
|
||||
commentRevisionID,
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommentTag } from "coral-server/models/comment/tag";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -9,19 +8,9 @@ import {
|
||||
GQLTAG,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
interface Result {
|
||||
status?: GQLCOMMENT_STATUS;
|
||||
tags: CommentTag[];
|
||||
}
|
||||
|
||||
export const approve: IntermediateModerationPhase = ({
|
||||
tags,
|
||||
now,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
const result: Result = {
|
||||
tags: [],
|
||||
};
|
||||
|
||||
// If the user is tagged STAFF or EXPERT then we approve
|
||||
// their comment.
|
||||
//
|
||||
@@ -34,8 +23,8 @@ export const approve: IntermediateModerationPhase = ({
|
||||
if (
|
||||
tags.some(tag => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT)
|
||||
) {
|
||||
result.status = GQLCOMMENT_STATUS.APPROVED;
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import {
|
||||
GQLTAG,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
import { hasStaffRole } from "coral-server/models/user/helpers";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
|
||||
import { GQLTAG } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
// If a given user is a staff member, always approve their comment.
|
||||
export const staff: IntermediateModerationPhase = ({
|
||||
author,
|
||||
now,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (author.role !== GQLUSER_ROLE.COMMENTER) {
|
||||
if (hasStaffRole(author)) {
|
||||
return {
|
||||
tags: [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CommentTag } from "coral-server/models/comment/tag";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
@@ -12,23 +13,33 @@ export const tagExpertAnswers: IntermediateModerationPhase = ({
|
||||
author,
|
||||
now,
|
||||
story,
|
||||
comment,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (
|
||||
// If we're in Q&A mode...
|
||||
story.settings.mode === GQLSTORY_MODE.QA &&
|
||||
// And we have experts for this story...
|
||||
story.settings.expertIDs &&
|
||||
// And the author is in expert list...
|
||||
story.settings.expertIDs.some(id => id === author.id)
|
||||
) {
|
||||
return {
|
||||
tags: [
|
||||
{
|
||||
type: GQLTAG.EXPERT,
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
type: GQLTAG.FEATURED,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
// Assign this comment an expert tag!
|
||||
const tags: CommentTag[] = [
|
||||
{
|
||||
type: GQLTAG.EXPERT,
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
// If this comment is the first reply in a thread (depth of 1)...
|
||||
if (comment.ancestorIDs.length === 1) {
|
||||
// Add the featured tag!
|
||||
tags.push({
|
||||
type: GQLTAG.FEATURED,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { tags };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { isNil } from "lodash";
|
||||
import path from "path";
|
||||
import { URL } from "url";
|
||||
|
||||
import {
|
||||
TOXICITY_ENDPOINT_DEFAULT,
|
||||
TOXICITY_MODEL_DEFAULT,
|
||||
TOXICITY_THRESHOLD_DEFAULT,
|
||||
} from "coral-common/constants";
|
||||
import { LanguageCode } from "coral-common/helpers";
|
||||
import { Omit } from "coral-common/types";
|
||||
import { ToxicCommentError } from "coral-server/errors";
|
||||
import { ACTION_TYPE } from "coral-server/models/action/comment";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
@@ -17,20 +12,14 @@ import {
|
||||
IntermediatePhaseResult,
|
||||
ModerationPhaseContext,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
import { sendToPerspective } from "coral-server/services/perspective";
|
||||
|
||||
import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLPerspectiveExternalIntegration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
/**
|
||||
* fetch is the phase hook fetcher used to communicate with the Perspective API.
|
||||
*/
|
||||
const fetch = createFetch({ name: "Hooks" });
|
||||
|
||||
export const toxic: IntermediateModerationPhase = async ({
|
||||
tenant,
|
||||
nudge,
|
||||
@@ -64,16 +53,6 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = integration.endpoint;
|
||||
if (isNil(endpoint)) {
|
||||
endpoint = TOXICITY_ENDPOINT_DEFAULT;
|
||||
|
||||
log.trace(
|
||||
{ endpoint },
|
||||
"endpoint missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
|
||||
let threshold = integration.threshold;
|
||||
if (isNil(threshold)) {
|
||||
threshold = TOXICITY_THRESHOLD_DEFAULT / 100;
|
||||
@@ -106,24 +85,24 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
// Pull the custom model out.
|
||||
const model = integration.model || TOXICITY_MODEL_DEFAULT;
|
||||
|
||||
// Get the language from the tenant's set language. This won't be a 1-1
|
||||
// mapping because the Perspective API doesn't support all the languages
|
||||
// that Coral supports in production.
|
||||
const language = convertLanguage(tenant.locale);
|
||||
|
||||
// Call into the Toxic comment API.
|
||||
const score = await getScore(
|
||||
htmlStripped,
|
||||
// Get the response from perspective.
|
||||
const result = await sendToPerspective(
|
||||
{ key: integration.key, endpoint: integration.endpoint, timeout },
|
||||
{
|
||||
endpoint,
|
||||
key: integration.key,
|
||||
doNotStore,
|
||||
model,
|
||||
},
|
||||
language,
|
||||
timeout
|
||||
operation: "comments:analyze",
|
||||
locale: tenant.locale,
|
||||
body: {
|
||||
text: htmlStripped,
|
||||
doNotStore,
|
||||
model,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Reformat the scores.
|
||||
const score = result.data.attributeScores[model].summaryScore
|
||||
.value as number;
|
||||
|
||||
const isToxic = score > threshold;
|
||||
if (isToxic) {
|
||||
// FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT
|
||||
@@ -183,92 +162,3 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
log.error({ err }, "could not determine comment toxicity");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Language is the language key that is supported by the Perspective API in the
|
||||
* ISO 631-1 format.
|
||||
*/
|
||||
type PerspectiveLanguage = "en" | "es" | "fr" | "de";
|
||||
|
||||
/**
|
||||
* convertLanguage returns the language code for the related Perspective API
|
||||
* model in the ISO 631-1 format.
|
||||
*
|
||||
* @param locale the language on the tenant in the BCP 47 format.
|
||||
*/
|
||||
function convertLanguage(locale: LanguageCode): PerspectiveLanguage {
|
||||
switch (locale) {
|
||||
case "en-US":
|
||||
return "en";
|
||||
case "es":
|
||||
return "es";
|
||||
case "de":
|
||||
return "de";
|
||||
default:
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getScore will return the toxicity score for the comment text.
|
||||
*
|
||||
* @param text comment text to check for toxicity
|
||||
* @param model the specific model to use when storing the toxicity
|
||||
* @param language language to run perspective api
|
||||
* @param timeout timeout for communicating with the perspective api
|
||||
*/
|
||||
async function getScore(
|
||||
text: string,
|
||||
{
|
||||
key,
|
||||
endpoint,
|
||||
model,
|
||||
doNotStore,
|
||||
}: Required<
|
||||
Omit<
|
||||
GQLPerspectiveExternalIntegration,
|
||||
"enabled" | "threshold" | "sendFeedback"
|
||||
>
|
||||
>,
|
||||
language: PerspectiveLanguage,
|
||||
timeout: number
|
||||
): Promise<number> {
|
||||
// Prepare the URL to send the command to.
|
||||
const url = new URL(endpoint.trim());
|
||||
url.pathname = path.join(url.pathname, "/comments:analyze");
|
||||
url.searchParams.set("key", key.trim());
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout,
|
||||
body: JSON.stringify({
|
||||
comment: {
|
||||
text,
|
||||
},
|
||||
languages: [language],
|
||||
doNotStore,
|
||||
requestedAttributes: {
|
||||
[model]: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Grab the data out of the Perspective API.
|
||||
const data = await response.json();
|
||||
|
||||
// Reformat the scores.
|
||||
return data.attributeScores[model].summaryScore.value as number;
|
||||
} catch (err) {
|
||||
// Ensure that the API key doesn't get leaked to the logs by accident.
|
||||
if (err.message) {
|
||||
err.message = err.message.replace(url.searchParams.toString(), "***");
|
||||
}
|
||||
|
||||
// Rethrow the error.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ModerationPhaseContextInput {
|
||||
log: Logger;
|
||||
story: Story;
|
||||
tenant: Tenant;
|
||||
comment: RequireProperty<Partial<CreateCommentInput>, "body">;
|
||||
comment: RequireProperty<Partial<CreateCommentInput>, "body" | "ancestorIDs">;
|
||||
author: User;
|
||||
now: Date;
|
||||
action: "NEW" | "EDIT";
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function publishCommentStatusChanges(
|
||||
oldStatus: GQLCOMMENT_STATUS,
|
||||
newStatus: GQLCOMMENT_STATUS,
|
||||
commentID: string,
|
||||
commentRevisionID: string,
|
||||
moderatorID: string | null
|
||||
) {
|
||||
if (oldStatus !== newStatus) {
|
||||
@@ -31,6 +32,7 @@ export async function publishCommentStatusChanges(
|
||||
newStatus,
|
||||
oldStatus,
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
moderatorID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,162 +1 @@
|
||||
import { Db } from "mongodb";
|
||||
import striptags from "striptags";
|
||||
|
||||
import { TOXICITY_ENDPOINT_DEFAULT } from "coral-common/constants";
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import { Config } from "coral-server/config";
|
||||
import logger from "coral-server/logger";
|
||||
import { Comment } from "coral-server/models/comment";
|
||||
import { getURLWithCommentID, retrieveStory } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLPerspectiveExternalIntegration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
interface SendResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: any;
|
||||
}
|
||||
|
||||
async function send(
|
||||
endpoint: string,
|
||||
apiKey: string,
|
||||
method: string,
|
||||
body: any
|
||||
): Promise<SendResult> {
|
||||
const result = await fetch(`${endpoint}/${method}?key=${apiKey}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body, null, 2),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await result.json();
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function computeStatus(status: GQLCOMMENT_STATUS) {
|
||||
if (status === GQLCOMMENT_STATUS.APPROVED) {
|
||||
return "APPROVED";
|
||||
}
|
||||
if (status === GQLCOMMENT_STATUS.REJECTED) {
|
||||
return "DELETED";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function notifyPerspectiveModerationDecision(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
perspectiveConfig: GQLPerspectiveExternalIntegration,
|
||||
comment: Comment,
|
||||
commentRevisionID: string,
|
||||
status: GQLCOMMENT_STATUS
|
||||
) {
|
||||
if (
|
||||
!perspectiveConfig.enabled ||
|
||||
!perspectiveConfig.key ||
|
||||
!perspectiveConfig.sendFeedback
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentStatus = computeStatus(status);
|
||||
if (!commentStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = comment.revisions.find(c => c.id === commentRevisionID);
|
||||
if (!revision) {
|
||||
logger.warn(
|
||||
{ commentID: comment.id, commentRevisionID },
|
||||
"unable to find comment revision ID in comment revision history"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = perspectiveConfig.endpoint
|
||||
? perspectiveConfig.endpoint
|
||||
: TOXICITY_ENDPOINT_DEFAULT;
|
||||
const apiKey = perspectiveConfig.key;
|
||||
|
||||
const tenantUrl = reconstructTenantURL(config, tenant, undefined, "/");
|
||||
const communityId = `Coral:${tenantUrl}`;
|
||||
const clientToken = `comment:${comment.id}`;
|
||||
|
||||
try {
|
||||
const story = await retrieveStory(mongo, comment.tenantID, comment.storyID);
|
||||
if (!story) {
|
||||
logger.warn({ storyID: comment.storyID }, "could not find story");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getURLWithCommentID(story.url, comment.id);
|
||||
|
||||
const body = {
|
||||
comment: {
|
||||
text: striptags(revision.body),
|
||||
},
|
||||
context: {
|
||||
entries: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
url,
|
||||
reply_to_id_Coral_comment_id: comment.parentID,
|
||||
Coral_comment_id: comment.id,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeScores: {
|
||||
[commentStatus]: {
|
||||
summaryScore: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
languages: ["EN"],
|
||||
communityId,
|
||||
clientToken,
|
||||
};
|
||||
|
||||
const result = await send(endpoint, apiKey, "comments:suggestscore", body);
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug(
|
||||
{ commentID: comment.id, commentRevisionID },
|
||||
"successfully sent moderation feedback to perspective"
|
||||
);
|
||||
} else if (!result.ok) {
|
||||
logger.error(
|
||||
{ status: result.status },
|
||||
"unable to send moderation feedback to perspective"
|
||||
);
|
||||
} else if (!result.data || result.data.clientToken !== clientToken) {
|
||||
logger.error(
|
||||
{ data: result.data },
|
||||
"result data from perspective did not contain the clientToken we expected"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "unable to send moderation feedback to perspective");
|
||||
}
|
||||
}
|
||||
export * from "./perspective";
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import path from "path";
|
||||
import { URL } from "url";
|
||||
|
||||
import { TOXICITY_ENDPOINT_DEFAULT } from "coral-common/constants";
|
||||
import { LanguageCode } from "coral-common/helpers";
|
||||
import { getURLWithCommentID } from "coral-server/models/story";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
|
||||
const fetch = createFetch({ name: "perspective" });
|
||||
|
||||
/**
|
||||
* Language is the language key that is supported by the Perspective API in the
|
||||
* ISO 631-1 format.
|
||||
*/
|
||||
type PerspectiveLanguage = "en" | "es" | "fr" | "de";
|
||||
|
||||
/**
|
||||
* convertLanguage returns the language code for the related Perspective API
|
||||
* model in the ISO 631-1 format.
|
||||
*
|
||||
* @param locale the language on the tenant in the BCP 47 format.
|
||||
*/
|
||||
function convertLanguage(locale: LanguageCode): PerspectiveLanguage {
|
||||
switch (locale) {
|
||||
case "en-US":
|
||||
return "en";
|
||||
case "es":
|
||||
return "es";
|
||||
case "de":
|
||||
return "de";
|
||||
default:
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
interface Options {
|
||||
endpoint?: string;
|
||||
key: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
type Request =
|
||||
| {
|
||||
operation: "comments:analyze";
|
||||
locale: LanguageCode;
|
||||
body: {
|
||||
/**
|
||||
* text is htmlStripped version of the comment text.
|
||||
*/
|
||||
text: string;
|
||||
doNotStore: boolean;
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
operation: "comments:suggestscore";
|
||||
locale: LanguageCode;
|
||||
body: {
|
||||
/**
|
||||
* text is htmlStripped version of the comment text.
|
||||
*/
|
||||
text: string;
|
||||
commentID: string;
|
||||
commentParentID?: string;
|
||||
commentStatus: "APPROVED" | "DELETED";
|
||||
storyURL: string;
|
||||
tenantURL: string;
|
||||
};
|
||||
};
|
||||
|
||||
function formatBody(req: Request): object {
|
||||
// Get the language from the locale. This won't be a 1-1 mapping because the
|
||||
// Perspective API doesn't support all the languages that Coral supports in
|
||||
// production.
|
||||
const language = convertLanguage(req.locale);
|
||||
|
||||
switch (req.operation) {
|
||||
case "comments:analyze":
|
||||
return {
|
||||
comment: {
|
||||
text: req.body.text,
|
||||
},
|
||||
doNotStore: req.body.doNotStore,
|
||||
requestedAttributes: {
|
||||
[req.body.model]: {},
|
||||
},
|
||||
languages: [language],
|
||||
};
|
||||
case "comments:suggestscore":
|
||||
return {
|
||||
comment: {
|
||||
text: req.body.text,
|
||||
},
|
||||
context: {
|
||||
entries: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
url: getURLWithCommentID(req.body.storyURL, req.body.commentID),
|
||||
reply_to_id_Coral_comment_id: req.body.commentParentID,
|
||||
Coral_comment_id: req.body.commentID,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeScores: {
|
||||
[status]: {
|
||||
summaryScore: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
languages: [language],
|
||||
communityId: `Coral:${req.body.tenantURL}`,
|
||||
clientToken: `comment:${req.body.commentID}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendToPerspective(
|
||||
{ endpoint = TOXICITY_ENDPOINT_DEFAULT, key, timeout }: Options,
|
||||
req: Request
|
||||
) {
|
||||
// Prepare the URL to send the command to.
|
||||
const url = new URL(endpoint.trim());
|
||||
url.pathname = path.join(url.pathname, `/${req.operation}`);
|
||||
url.searchParams.set("key", key.trim());
|
||||
|
||||
// Convert the request to a body to be sent to perspective.
|
||||
const body = JSON.stringify(formatBody(req));
|
||||
|
||||
try {
|
||||
// Create the request and send it.
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout,
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the JSON body and send back the result!
|
||||
const data = await res.json();
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
data,
|
||||
};
|
||||
} catch (err) {
|
||||
// Ensure that the API key doesn't get leaked to the logs by accident.
|
||||
if (err.message) {
|
||||
err.message = err.message.replace(
|
||||
url.searchParams.toString(),
|
||||
"[Sensitive]"
|
||||
);
|
||||
}
|
||||
|
||||
// Rethrow the error.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { moderate } from "coral-server/services/comments/moderation";
|
||||
import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
|
||||
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
|
||||
@@ -14,7 +12,6 @@ import { publishChanges, updateAllCommentCounts } from "./helpers";
|
||||
const approveComment = async (
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
commentID: string,
|
||||
@@ -48,19 +45,8 @@ const approveComment = async (
|
||||
...result,
|
||||
...counts,
|
||||
moderatorID,
|
||||
});
|
||||
|
||||
// We don't want to await on this so that
|
||||
// we don't hold up the moderation flow and response
|
||||
notifyPerspectiveModerationDecision(
|
||||
mongo,
|
||||
tenant,
|
||||
config,
|
||||
tenant.integrations.perspective,
|
||||
result.after,
|
||||
commentRevisionID,
|
||||
GQLCOMMENT_STATUS.APPROVED
|
||||
);
|
||||
});
|
||||
|
||||
// Return the resulting comment.
|
||||
return result.after;
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
retrieveComment,
|
||||
} from "coral-server/models/comment";
|
||||
import {
|
||||
getLatestRevision,
|
||||
hasAncestors,
|
||||
hasPublishedStatus,
|
||||
} from "coral-server/models/comment/helpers";
|
||||
@@ -43,10 +42,6 @@ import {
|
||||
PhaseResult,
|
||||
processForModeration,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
import {
|
||||
publishCommentCreated,
|
||||
publishCommentReplyCreated,
|
||||
} from "coral-server/services/events";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import { updateUserLastCommentID } from "coral-server/services/users";
|
||||
import { Request } from "coral-server/types/express";
|
||||
@@ -67,7 +62,6 @@ export type CreateComment = Omit<
|
||||
const markCommentAsAnswered = async (
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
comment: Readonly<Comment>,
|
||||
@@ -93,16 +87,19 @@ const markCommentAsAnswered = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have experts and this reply is created by
|
||||
// one of them, then this is an expert's answer.
|
||||
if (story.settings.expertIDs.some(id => id === author.id)) {
|
||||
// We need to mark this question as answered now.
|
||||
// We can now remove the unanswered tag.
|
||||
if (
|
||||
// If we are the export on this story...
|
||||
story.settings.expertIDs.some(id => id === author.id) &&
|
||||
// And this is the first reply (depth of 1)...
|
||||
comment.ancestorIDs.length === 1
|
||||
) {
|
||||
// We need to mark the parent question as answered.
|
||||
// - Remove the unanswered tag.
|
||||
// - Approve it since an expert has replied to it.
|
||||
await removeTag(mongo, tenant, comment.parentID, GQLTAG.UNANSWERED);
|
||||
await approveComment(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
broker,
|
||||
tenant,
|
||||
comment.parentID,
|
||||
@@ -181,7 +178,7 @@ export default async function create(
|
||||
nudge,
|
||||
story,
|
||||
tenant,
|
||||
comment: input,
|
||||
comment: { ...input, ancestorIDs },
|
||||
author,
|
||||
req,
|
||||
now,
|
||||
@@ -218,7 +215,7 @@ export default async function create(
|
||||
}
|
||||
|
||||
// Create the comment!
|
||||
const comment = await createComment(
|
||||
const { comment, revision } = await createComment(
|
||||
mongo,
|
||||
tenant.id,
|
||||
{
|
||||
@@ -234,6 +231,11 @@ export default async function create(
|
||||
now
|
||||
);
|
||||
|
||||
log = log.child(
|
||||
{ commentID: comment.id, status, revisionID: revision.id },
|
||||
true
|
||||
);
|
||||
|
||||
// Updating some associated data.
|
||||
await Promise.all([
|
||||
updateUserLastCommentID(redis, tenant, author, comment.id),
|
||||
@@ -241,7 +243,6 @@ export default async function create(
|
||||
markCommentAsAnswered(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
broker,
|
||||
tenant,
|
||||
comment,
|
||||
@@ -251,14 +252,6 @@ export default async function create(
|
||||
),
|
||||
]);
|
||||
|
||||
// Pull the revision out.
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
log = log.child(
|
||||
{ commentID: comment.id, status, revisionID: revision.id },
|
||||
true
|
||||
);
|
||||
|
||||
log.trace("comment created");
|
||||
|
||||
if (input.parentID) {
|
||||
@@ -305,17 +298,8 @@ export default async function create(
|
||||
await publishChanges(broker, {
|
||||
...counts,
|
||||
after: comment,
|
||||
commentRevisionID: revision.id,
|
||||
});
|
||||
|
||||
// If this is a reply, publish it.
|
||||
if (input.parentID) {
|
||||
publishCommentReplyCreated(broker, comment);
|
||||
}
|
||||
|
||||
// If this comment is visible (and not a reply), publish it.
|
||||
if (!input.parentID && hasPublishedStatus(comment)) {
|
||||
publishCommentCreated(broker, comment);
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ export default async function edit(
|
||||
await publishChanges(broker, {
|
||||
...result,
|
||||
...counts,
|
||||
commentRevisionID: result.revision.id,
|
||||
});
|
||||
|
||||
// Return the resulting comment.
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
hasPublishedStatus,
|
||||
} from "coral-server/models/comment";
|
||||
import {
|
||||
publishCommentCreated,
|
||||
publishCommentReleased,
|
||||
publishCommentReplyCreated,
|
||||
publishCommentStatusChanges,
|
||||
publishModerationQueueChanges,
|
||||
} from "coral-server/services/events";
|
||||
@@ -16,6 +18,7 @@ interface PublishChangesInput {
|
||||
after: Readonly<Comment>;
|
||||
moderationQueue: CommentModerationQueueCounts;
|
||||
moderatorID?: string;
|
||||
commentRevisionID: string;
|
||||
}
|
||||
|
||||
export default async function publishChanges(
|
||||
@@ -33,11 +36,24 @@ export default async function publishChanges(
|
||||
input.before.status,
|
||||
input.after.status,
|
||||
input.after.id,
|
||||
input.commentRevisionID,
|
||||
input.moderatorID || null
|
||||
);
|
||||
|
||||
if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) {
|
||||
publishCommentReleased(broker, input.after);
|
||||
}
|
||||
} else {
|
||||
// This block is only hit if there is no before (if this is a new comment).
|
||||
|
||||
// If this is a reply, publish it.
|
||||
if (input.after.parentID) {
|
||||
publishCommentReplyCreated(broker, input.after);
|
||||
}
|
||||
|
||||
// If this comment is visible (and not a reply), publish it.
|
||||
if (!input.after.parentID && hasPublishedStatus(input.after)) {
|
||||
publishCommentCreated(broker, input.after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import { hasTag } from "coral-server/models/comment";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { removeTag } from "coral-server/services/comments";
|
||||
import { moderate } from "coral-server/services/comments/moderation";
|
||||
import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
|
||||
import {
|
||||
@@ -19,7 +17,6 @@ import { publishChanges, updateAllCommentCounts } from "./helpers";
|
||||
const rejectComment = async (
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
broker: CoralEventPublisherBroker | null,
|
||||
tenant: Tenant,
|
||||
commentID: string,
|
||||
@@ -56,6 +53,7 @@ const rejectComment = async (
|
||||
...result,
|
||||
...counts,
|
||||
moderatorID,
|
||||
commentRevisionID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,18 +62,6 @@ const rejectComment = async (
|
||||
return removeTag(mongo, tenant, result.after.id, GQLTAG.FEATURED);
|
||||
}
|
||||
|
||||
// We don't want to await on this so that
|
||||
// we don't hold up the moderation flow and response
|
||||
notifyPerspectiveModerationDecision(
|
||||
mongo,
|
||||
tenant,
|
||||
config,
|
||||
tenant.integrations.perspective,
|
||||
result.after,
|
||||
commentRevisionID,
|
||||
GQLCOMMENT_STATUS.REJECTED
|
||||
);
|
||||
|
||||
// Return the resulting comment.
|
||||
return result.after;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user