Skip to content

Commit

Permalink
Merge pull request #80 from IGAQ/ilia-add-moderation-module
Browse files Browse the repository at this point in the history
Ilia add moderation module
  • Loading branch information
iantelli authored Nov 17, 2022
2 parents e81ea26 + 4a446fb commit 6bc3f91
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 156 deletions.
3 changes: 3 additions & 0 deletions src/_domain/injectableTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ const injectableTokens = {

// Neo4j Module
INeo4jService: Symbol("INeo4jService"),

// Moderation Module
IAutoModerationService: Symbol("IAutoModerationService"),
};
export { injectableTokens as _$ };
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PostsModule } from "./posts/posts.module";
import { UsersModule } from "./users/users.module";
import { AppLoggerMiddleware } from "./_domain/middlewares/appLogger.middleware";
import { neo4jCredentials } from "./_domain/constants";
import { ModerationModule } from './moderation/moderation.module';

@Module({
imports: [
Expand All @@ -36,6 +37,7 @@ import { neo4jCredentials } from "./_domain/constants";
PostsModule,
CommentsModule,
DatabaseAccessLayerModule,
ModerationModule,
],
})
export class AppModule {
Expand Down
3 changes: 2 additions & 1 deletion src/comments/comments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { CommentsRepository } from "./repositories/comment/comments.repository";
import { DatabaseAccessLayerModule } from "../database-access-layer/database-access-layer.module";
import { forwardRef, Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ModerationModule } from "../moderation/moderation.module";

@Module({
controllers: [CommentsController],
imports: [forwardRef(() => DatabaseAccessLayerModule), HttpModule],
imports: [forwardRef(() => DatabaseAccessLayerModule), HttpModule, ModerationModule],
providers: [
{
provide: _$.ICommentsService,
Expand Down
92 changes: 14 additions & 78 deletions src/comments/services/comments/comments.service.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,51 @@
import { HttpService } from "@nestjs/axios";
import { HttpException, Inject, Injectable, Logger, Scope } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpException, Inject, Injectable, Scope } from "@nestjs/common";
import { REQUEST } from "@nestjs/core";
import { Request } from "express";
import { catchError, lastValueFrom, map, throwError } from "rxjs";
import { DatabaseContext } from "../../../database-access-layer/databaseContext";
import { Post } from "../../../posts/models";
import { PostToCommentRelTypes } from "../../../posts/models/toComment";
import { User } from "../../../users/models";
import { UserToCommentRelTypes } from "../../../users/models/toComment";
import { WasOffendingProps } from "../../../users/models/toSelf";
import { _$ } from "../../../_domain/injectableTokens";
import {
CommentCreationPayloadDto,
HateSpeechRequestPayloadDto,
HateSpeechResponseDto,
VoteCommentPayloadDto,
VoteType,
} from "../../dtos";
import { CommentCreationPayloadDto, VoteCommentPayloadDto, VoteType } from "../../dtos";
import { Comment } from "../../models";
import { CommentToSelfRelTypes, DeletedProps, RepliedProps } from "../../models/toSelf";
import { CommentToSelfRelTypes, DeletedProps } from "../../models/toSelf";
import { ICommentsService } from "./comments.service.interface";
import { IAutoModerationService } from "../../../moderation/services/autoModeration/autoModeration.service.interface";

@Injectable({ scope: Scope.REQUEST })
export class CommentsService implements ICommentsService {
private readonly _logger = new Logger(CommentsService.name);
private readonly _request: Request;
private readonly _dbContext: DatabaseContext;
private readonly _httpService: HttpService;
private readonly _configService: ConfigService;
private readonly _autoModerationService: IAutoModerationService;

constructor(
@Inject(REQUEST) request: Request,
@Inject(_$.IDatabaseContext) databaseContext: DatabaseContext,
httpService: HttpService,
configService: ConfigService
@Inject(_$.IAutoModerationService) autoModerationService: IAutoModerationService
) {
this._request = request;
this._dbContext = databaseContext;
this._httpService = httpService;
this._configService = configService;
this._autoModerationService = autoModerationService;
}

public async authorNewComment(commentPayload: CommentCreationPayloadDto): Promise<Comment> {
const user = this.getUserFromRequest();

// auto-moderation
const autoModerationApiKey = this._configService.get<string>("MODERATE_HATESPEECH_API_KEY");
const autoModerationApiUrl = this._configService.get<string>("MODERATE_HATESPEECH_API_URL");

const hateSpeechResponseDto = await lastValueFrom(
this._httpService
.post<HateSpeechResponseDto>(
autoModerationApiUrl,
new HateSpeechRequestPayloadDto({
token: autoModerationApiKey,
text: commentPayload.commentContent,
})
)
.pipe(
map(response => response.data),
catchError(err => {
this._logger.error(err);
return throwError(() => err);
})
)
const wasOffending = await this._autoModerationService.checkForHateSpeech(
commentPayload.commentContent
);

// if moderation failed, throw error
if (hateSpeechResponseDto.class === "flag") {
if (hateSpeechResponseDto.confidence >= 0.8) {
// TODO: create a ticket for the admin to review

await user.addWasOffendingRecord(
new WasOffendingProps({
timestamp: new Date().getTime(),
userContent: commentPayload.commentContent,
autoModConfidenceLevel: hateSpeechResponseDto.confidence,
})
);
throw new HttpException("Hate speech detected", 400);
}
}

// get all records of them offending. And, get all comments that this user authored.
const userOffendingRecords = await user.getWasOffendingRecords();
const userAuthoredComments = await user.getAuthoredComments();

// lazy-query the restriction state of the comments.
for (const i in userAuthoredComments) {
await userAuthoredComments[i].getRestricted();
}

// calculate the honour level of the user. (0 < honourLevel < 1)
const numberOfCleanComments = userAuthoredComments.map(
c => !c.pending && c.restrictedProps === null
).length;

const honourGainHardshipCoefficient = 0.1; // 0.1 means: for the user to achieve the full level of honour, they need at least 10 clean comments while not having any offending records.

let honourLevel =
(1 + numberOfCleanComments * honourGainHardshipCoefficient) /
(2 + userOffendingRecords.length);
honourLevel = honourLevel > 1 ? 1 : honourLevel;

// if moderation passed, create comment and return it.
if (commentPayload.isPost) {
return await this._dbContext.Comments.addCommentToPost(
new Comment({
commentContent: commentPayload.commentContent,
authorUser: user,
pending: honourLevel < 0.4,
pending: wasOffending,
updatedAt: new Date().getTime(),
parentId: commentPayload.parentId,
})
Expand All @@ -120,7 +56,7 @@ export class CommentsService implements ICommentsService {
new Comment({
commentContent: commentPayload.commentContent,
authorUser: user,
pending: honourLevel < 0.4,
pending: wasOffending,
updatedAt: new Date().getTime(),
parentId: commentPayload.parentId,
})
Expand Down Expand Up @@ -258,7 +194,7 @@ export class CommentsService implements ICommentsService {
}

// gets the parent comment of any nested comment of the post
private async findComment(commentId: string): Promise<Boolean> {
private async findComment(commentId: string): Promise<boolean> {
const queryResult = await this._dbContext.neo4jService.tryReadAsync(
`
MATCH (c:Comment { commentId: $commentId })-[:${CommentToSelfRelTypes.REPLIED}]->(commentParent:Comment)
Expand All @@ -274,7 +210,7 @@ export class CommentsService implements ICommentsService {
// gets the root comment of any nested comment
private async findParentCommentRoot(
commentId: string,
isNestedComment: boolean = false
isNestedComment = false
): Promise<[Post, boolean]> {
const queryResult = await this._dbContext.neo4jService.tryReadAsync(
`
Expand Down
21 changes: 21 additions & 0 deletions src/moderation/moderation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { AutoModerationService } from "./services/autoModeration/autoModeration.service";
import { _$ } from "../_domain/injectableTokens";
import { HttpModule } from "@nestjs/axios";

@Module({
imports: [HttpModule],
providers: [
{
provide: _$.IAutoModerationService,
useClass: AutoModerationService,
},
],
exports: [
{
provide: _$.IAutoModerationService,
useClass: AutoModerationService,
},
],
})
export class ModerationModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface IAutoModerationService {
checkForHateSpeech(text: string): Promise<boolean>;
}
98 changes: 98 additions & 0 deletions src/moderation/services/autoModeration/autoModeration.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Request } from "express";
import { REQUEST } from "@nestjs/core";
import { HttpException, Inject, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { catchError, lastValueFrom, map, throwError } from "rxjs";
import { HttpService } from "@nestjs/axios";
import { IAutoModerationService } from "./autoModeration.service.interface";
import { HateSpeechRequestPayloadDto, HateSpeechResponseDto } from "../../../posts/dtos";
import { WasOffendingProps } from "../../../users/models/toSelf";
import { User } from "../../../users/models";

export class AutoModerationService implements IAutoModerationService {
private readonly _logger = new Logger(AutoModerationService.name);
private readonly _request: Request;
private readonly _configService: ConfigService;
private readonly _httpService: HttpService;

constructor(
@Inject(REQUEST) request: Request,
configService: ConfigService,
httpService: HttpService
) {
this._request = request;
this._configService = configService;
this._httpService = httpService;
}

public async checkForHateSpeech(text: string): Promise<boolean> {
const user = this.getUserFromRequest();

const autoModerationApiKey = this._configService.get<string>("MODERATE_HATESPEECH_API_KEY");
const autoModerationApiUrl = this._configService.get<string>("MODERATE_HATESPEECH_API_URL");

const hateSpeechResponseDto = await lastValueFrom(
this._httpService
.post<HateSpeechResponseDto>(
autoModerationApiUrl,
new HateSpeechRequestPayloadDto({
token: autoModerationApiKey,
text,
})
)
.pipe(
map(response => response.data),
catchError(err => {
this._logger.error(err);
return throwError(() => err);
})
)
);

// if moderation failed, throw error
if (hateSpeechResponseDto.class === "flag") {
if (hateSpeechResponseDto.confidence >= 0.9) {
// TODO: create a ticket for the admin to review

await user.addWasOffendingRecord(
new WasOffendingProps({
timestamp: new Date().getTime(),
userContent: text,
autoModConfidenceLevel: hateSpeechResponseDto.confidence,
})
);
throw new HttpException("Hate speech detected", 400);
}
}

// get all records of them offending. And, get all posts that this user authored.
const userOffendingRecords = await user.getWasOffendingRecords();
const userAuthoredPosts = await user.getAuthoredPosts();

// lazy-query the restriction state of the posts.
for (const i in userAuthoredPosts) {
await userAuthoredPosts[i].getRestricted();
}

// calculate the honour level of the user. (0 < honourLevel < 1)
const numberOfCleanPosts = userAuthoredPosts.map(
p => !p.pending && p.restrictedProps === null
).length;

const honourGainHardshipCoefficient = 0.1; // 0.1 means: for the user to achieve the full level of honour, they need at least 10 clean posts while not having any offending records.

let honourLevel =
(1 + numberOfCleanPosts * honourGainHardshipCoefficient) /
(2 + userOffendingRecords.length);
honourLevel = honourLevel > 1 ? 1 : honourLevel;

// the post will not be in the pending state only if user's honour level is higher than 0.4
return honourLevel < 0.4;
}

private getUserFromRequest(): User {
const user = this._request.user as User;
if (user === undefined) throw new Error("User not found");
return user;
}
}
3 changes: 2 additions & 1 deletion src/posts/posts.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { PostTagsRepository } from "./repositories/postTag/postTags.repository";
import { PostTypesRepository } from "./repositories/postType/postTypes.repository";
import { PostAwardRepository } from "./repositories/postAward/postAward.repository";
import { PostTypesController } from "./controllers/postTypes.controller";
import { ModerationModule } from "../moderation/moderation.module";

@Module({
imports: [forwardRef(() => DatabaseAccessLayerModule), HttpModule],
imports: [forwardRef(() => DatabaseAccessLayerModule), HttpModule, ModerationModule],
providers: [
{
provide: _$.IPostsRepository,
Expand Down
Loading

0 comments on commit 6bc3f91

Please sign in to comment.