Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added client/src/images/RobloxLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions client/src/models/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export function integrationForSignalType(type: string) {
case 'OPEN_AI_VIOLENCE_TEXT_MODEL':
case 'OPEN_AI_WHISPER_TRANSCRIPTION':
return GQLIntegration.OpenAi;
case 'SENTINEL_RARE_CLASS_AFFINITY':
return GQLIntegration.Sentinel;
case 'ZENTROPI_LABELER':
return GQLIntegration.Zentropi;
case 'AGGREGATION':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import GoogleLogo from '../../../images/GoogleLogo.png';
import GoogleLogoWithBackground from '../../../images/GoogleLogoWithBackground.png';
import OpenAILogo from '../../../images/OpenAILogo.png';
import OpenAILogoWithBackground from '../../../images/OpenAILogoWithBackground.png';
import RobloxLogo from '../../../images/RobloxLogo.png';
import ZentropiLogo from '../../../images/ZentropiLogo.png';

export type IntegrationConfig = {
Expand Down Expand Up @@ -36,6 +37,14 @@ export const INTEGRATION_CONFIGS: IntegrationConfig[] = [
url: 'https://openai.com/',
requiresInfo: true,
},
{
name: 'SENTINEL',
title: 'Sentinel',
logo: RobloxLogo,
logoWithBackground: RobloxLogo,
url: 'https://github.com/UMass-Rescue/Sentinel',
requiresInfo: false,
},
{
name: 'ZENTROPI',
title: 'Zentropi',
Expand Down
4 changes: 4 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ CLICKHOUSE_PROTOCOL=http

HMA_SERVICE_URL=http://localhost:9876

# Sentinel API URL (local Sentinel service for rare class affinity signal)
# Start Sentinel locally: docker compose -f docker-compose.yaml -f docker-compose.sentinel.yaml up -d sentinel
SENTINEL_API_URL=http://localhost:8000

# Scylla Cluster Details
SCYLLA_USERNAME=cassandra
SCYLLA_PASSWORD=cassandra
Expand Down
31 changes: 30 additions & 1 deletion server/condition_evaluator/leafCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
getScalarType,
ScalarTypes,
type ItemIdentifier,
type ScalarType,
type TaggedScalar,
} from '@roostorg/coop-types';
Expand Down Expand Up @@ -198,7 +199,12 @@ export async function runLeafCondition(
: undefined,
args: signal.args,
runtimeArgs: isFullSubmission(ruleInput)
? await getSignalRuntimeArgs(evaluationContext, ruleInput, signal)
? await getSignalRuntimeArgs(
evaluationContext,
ruleInput,
signal,
conditionInput,
)
: undefined,
});
}),
Expand Down Expand Up @@ -551,6 +557,7 @@ async function getSignalRuntimeArgs(
evaluationContext: RuleEvaluationContext,
itemSubmission: ItemSubmission,
conditionSignalInfo: ReadonlyDeep<ConditionSignalInfo>,
conditionInput: ReadonlyDeep<ConditionInput>,
) {
if (conditionSignalInfo.type === 'AGGREGATION') {
const args = conditionSignalInfo.args;
Expand All @@ -562,6 +569,28 @@ async function getSignalRuntimeArgs(
);
}
}

if (conditionSignalInfo.type === 'SENTINEL_RARE_CLASS_AFFINITY') {
// Extract the thread item identifier from the content schema's threadId
// role — this lets Sentinel score the content in its conversational context.
const threadIdentifier: ItemIdentifier | undefined =
itemSubmission.itemType.kind === 'CONTENT'
? (getFieldValueForRole(
itemSubmission.itemType.schema,
itemSubmission.itemType.schemaFieldRoles,
'threadId',
itemSubmission.data,
) ?? undefined)
: undefined;

// The field name the condition is evaluating is the most relevant text to
// score. If the condition input is a CONTENT_FIELD, use that field name.
const contentTextFieldName: string | undefined =
conditionInput.type === 'CONTENT_FIELD' ? conditionInput.name : undefined;

return { threadIdentifier, contentTextFieldName };
}

return undefined;
}

Expand Down
1 change: 1 addition & 0 deletions server/decs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,5 +265,6 @@ namespace NodeJS {
OPEN_AI_API_KEY?: string;
SLACK_APP_BEARER_TOKEN?: string;
GRAPHQL_OPAQUE_SCALAR_SECRET?: string;
SENTINEL_API_URL?: string;
}
}
2 changes: 2 additions & 0 deletions server/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/graphql/modules/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const typeDefs = /* GraphQL */ `
enum Integration {
GOOGLE_CONTENT_SAFETY_API
OPEN_AI
SENTINEL
ZENTROPI
}

Expand Down
24 changes: 18 additions & 6 deletions server/graphql/modules/itemType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ const Mutation: GQLMutationResolvers = {
}

return gqlSuccessResult(
contentItemType,
{ data: contentItemType },
'MutateContentTypeSuccessResponse',
);
},
Expand Down Expand Up @@ -845,7 +845,7 @@ const Mutation: GQLMutationResolvers = {
}

return gqlSuccessResult(
contentItemType,
{ data: contentItemType },
'MutateContentTypeSuccessResponse',
);
},
Expand Down Expand Up @@ -883,7 +883,10 @@ const Mutation: GQLMutationResolvers = {
);
}

return gqlSuccessResult(threadItemType, 'MutateThreadTypeSuccessResponse');
return gqlSuccessResult(
{ data: threadItemType },
'MutateThreadTypeSuccessResponse',
);
},
async updateThreadItemType(_, params, context) {
const user = context.getUser();
Expand Down Expand Up @@ -927,7 +930,10 @@ const Mutation: GQLMutationResolvers = {
);
}

return gqlSuccessResult(threadItemType, 'MutateThreadTypeSuccessResponse');
return gqlSuccessResult(
{ data: threadItemType },
'MutateThreadTypeSuccessResponse',
);
},
async createUserItemType(__, params, context) {
const user = context.getUser();
Expand Down Expand Up @@ -966,7 +972,10 @@ const Mutation: GQLMutationResolvers = {
);
}

return gqlSuccessResult(userItemType, 'MutateUserTypeSuccessResponse');
return gqlSuccessResult(
{ data: userItemType },
'MutateUserTypeSuccessResponse',
);
},
async updateUserItemType(_, params, context) {
const user = context.getUser();
Expand Down Expand Up @@ -1008,7 +1017,10 @@ const Mutation: GQLMutationResolvers = {
);
}

return gqlSuccessResult(contentItemType, 'MutateUserTypeSuccessResponse');
return gqlSuccessResult(
{ data: contentItemType },
'MutateUserTypeSuccessResponse',
);
},
async deleteItemType(_, params, context) {
const user = context.getUser();
Expand Down
1 change: 1 addition & 0 deletions server/graphql/modules/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const typeDefs = /* GraphQL */ `
OPEN_AI_VIOLENCE_IMAGE_MODEL
OPEN_AI_VIOLENCE_TEXT_MODEL
OPEN_AI_WHISPER_TRANSCRIPTION
SENTINEL_RARE_CLASS_AFFINITY
ZENTROPI_LABELER
GEO_CONTAINED_WITHIN
USER_SCORE
Expand Down
2 changes: 1 addition & 1 deletion server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@opentelemetry/semantic-conventions": "^1.22.0",
"@roostorg/coop-integration-example": "^2.0.0",
"@roostorg/coop-types": "^2.4.0",
"@roostorg/types": "^2.1.0",
"@sendgrid/mail": "^8.1.6",
"@stdlib/stats-binomial-test": "^0.0.7",
"@total-typescript/ts-reset": "^0.3.7",
Expand Down
57 changes: 55 additions & 2 deletions server/routes/content/submitContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
sanitizeError,
type SerializableError,
} from '../../utils/errors.js';
import { safeGet, safePick, sleep } from '../../utils/misc.js';
import { safeGet, safePick, sleep, withRetries } from '../../utils/misc.js';
import { type RequestHandlerWithBodies } from '../../utils/route-helpers.js';
import { instantiateOpaqueType } from '../../utils/typescript-types.js';
import {
Expand All @@ -46,6 +46,7 @@ export default function submitContent({
Tracer,
ModerationConfigService,
Meter,
ItemInvestigationService,
}: // @ts-ignore
Dependencies): RequestHandlerWithBodies<
EvaluateContentInputCamelCase,
Expand Down Expand Up @@ -193,6 +194,37 @@ Dependencies): RequestHandlerWithBodies<
res.status(202).end();
}

// Write to Scylla before running rules so that thread-aware signals
// (e.g. Sentinel) can read prior messages in the thread when scoring.
// This is best-effort: failures are swallowed so they never block rule execution.
const insertWithRetries = Tracer.traced(
{
resource: 'SubmitContent',
operation: 'ItemInvestigationService.insertItem',
},
withRetries(
{
maxRetries: 1,
initialTimeMsBetweenRetries: 50,
maxTimeMsBetweenRetries: 200,
},
ItemInvestigationService.insertItem.bind(ItemInvestigationService),
),
);
try {
await insertWithRetries({
requestId,
orgId,
itemSubmission: {
...itemSubmissionOrError.itemSubmission,
submissionTime:
itemSubmissionOrError.itemSubmission.submissionTime ?? new Date(),
},
});
} catch {
// Swallow insert errors; rules can still run without thread context.
}

// Run rules
try {
const results = await RuleEngine.runEnabledRules(
Expand Down Expand Up @@ -328,11 +360,32 @@ function rawContentSubmissionToItemSubmission(
| { itemSubmission: ItemSubmission; error?: undefined } {
const submissionTime = new Date();

// Coerce a string threadId to { id, typeId } when the content type has a
// threadId schema field role. This allows callers to pass a plain string ID
// for the parent thread without knowing the internal identifier format.
const raw = rawContentSubmission.content;
const threadIdField =
'threadId' in itemType.schemaFieldRoles
? (itemType.schemaFieldRoles as { threadId?: string }).threadId
: undefined;
const content: RawItemData =
threadIdField &&
typeof raw[threadIdField] === 'string' &&
(raw[threadIdField] as string).length > 0
? {
...raw,
[threadIdField]: {
id: raw[threadIdField] as string,
typeId: itemType.id,
},
}
: raw;

// Validate content JSON
const normalizedDataOrValidationErrors = toNormalizedItemDataOrErrors(
legalItemTypeIds,
itemType,
rawContentSubmission.content,
content,
);

return Array.isArray(normalizedDataOrValidationErrors)
Expand Down
10 changes: 10 additions & 0 deletions server/services/sentinelService/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
makeSentinelService,
SentinelServiceError,
type SentinelBanksStatus,
type SentinelHealthResponse,
type SentinelLoadBanksRequest,
type SentinelScoreRequest,
type SentinelScoreResponse,
type SentinelService,
} from './sentinelService.js';
Loading
Loading