diff --git a/backend/src/business/email/templates/StoryUserEmailTemplateData.ts b/backend/src/business/email/templates/StoryUserEmailTemplateData.ts index c98dd3ea..6a0feb99 100644 --- a/backend/src/business/email/templates/StoryUserEmailTemplateData.ts +++ b/backend/src/business/email/templates/StoryUserEmailTemplateData.ts @@ -6,6 +6,7 @@ export interface StoryEmailTemplateData { photoThumbnailUrl: string; mapImageUrl: string | null; mapImageUrlRetina: string | null; + isPublished: boolean; } export interface StoryEmailMetadata { diff --git a/backend/src/business/moderation/AiStoryModerationService.ts b/backend/src/business/moderation/AiStoryModerationService.ts index bac9ec91..b3b02308 100644 --- a/backend/src/business/moderation/AiStoryModerationService.ts +++ b/backend/src/business/moderation/AiStoryModerationService.ts @@ -6,14 +6,20 @@ import { buildState, combine, AiModerationFlag, + AiStoryModerationScore, } from './moderationRules'; const MODEL = 'typesafe/jev-1.13'; -const client = new OpenRouter({ apiKey: process.env.OPENROUTER_SK }); +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_SK, + timeoutMs: 5000, +}); const questions = buildNoulQuestions(); -export async function evaluateStory(story: Story): Promise { +export async function evaluateStory( + story: Story +): Promise { const response = await client.alpha.decisions.create({ decisionsRequest: { model: MODEL, @@ -36,13 +42,15 @@ export async function evaluateStory(story: Story): Promise { const { approveProbability } = combine(ruleProbabilities); - await StoryRepository().update(story.id, { - aiModerationScore: { - model: response.model ?? MODEL, - evaluatedAt: new Date().toISOString(), - costUsd: response.usage.cost ?? null, - approveProbability, - ruleProbabilities, - }, - }); + const score: AiStoryModerationScore = { + model: response.model ?? MODEL, + evaluatedAt: new Date().toISOString(), + costUsd: response.usage.cost ?? null, + approveProbability, + ruleProbabilities, + }; + + await StoryRepository().update(story.id, { aiModerationScore: score }); + + return score; } diff --git a/backend/src/business/stories/StoriesService.ts b/backend/src/business/stories/StoriesService.ts index 5d569cdb..e4d9d61c 100644 --- a/backend/src/business/stories/StoriesService.ts +++ b/backend/src/business/stories/StoriesService.ts @@ -4,6 +4,7 @@ import User from '../../entities/User'; import StoryState from '../../enum/StoryState'; import StoryRepository from '../../repositories/StoryRepository'; import { evaluateStory } from '../moderation/AiStoryModerationService'; +import { AiModerationFlag, combine } from '../moderation/moderationRules'; import { sendPublishedEmail, sendSubmittedAgainEmail, @@ -11,6 +12,10 @@ import { sendUserRemovedEmail, } from './StoryUserEmailService'; +// Matches the "% Human" flag shown in the admin review screen +// (frontend/.../ReviewStories/index.tsx). +const MIN_RECAPTCHA_SCORE_TO_AUTO_PUBLISH = 0.7; + function getStoryOrThrow( storyId: Story['id'], state: StoryState @@ -27,26 +32,23 @@ async function onStorySubmitted(storyId: Story['id']): Promise { const hasSubmittedBefore = story.hasEverSubmitted; + let ruleProbabilities: Record | null = null; try { - if (hasSubmittedBefore) { - await sendSubmittedAgainEmail(story); - return; - } - await sendSubmittedEmail(story); + ({ ruleProbabilities } = await evaluateStory(story)); } catch (e) { - console.error('Error sending story submitted email', e); + console.error('Error evaluating story with AI moderation', e); } await StoryRepository().update(story.id, { hasEverSubmitted: true, }); + let isUserBanned = false; try { - // If user with this email is banned, reject the story const maybeUser = await userRepository.findOneBy({ email: story.storytellerEmail ?? '', }); - const isUserBanned = maybeUser?.isBanned ?? false; + isUserBanned = maybeUser?.isBanned ?? false; if (isUserBanned) { await StoryRepository().update(story.id, { state: StoryState.REJECTED, @@ -57,10 +59,32 @@ async function onStorySubmitted(storyId: Story['id']): Promise { console.error('Error auto-reviewing story', e); } + let isAutoPublished = false; try { - await evaluateStory(story); + if ( + !isUserBanned && + ruleProbabilities && + story.recaptchaScore >= MIN_RECAPTCHA_SCORE_TO_AUTO_PUBLISH && + combine(ruleProbabilities).approve + ) { + await StoryRepository().update(story.id, { + state: StoryState.PUBLISHED, + lastReviewer: 'system', + }); + isAutoPublished = true; + } } catch (e) { - console.error('Error evaluating story with AI moderation', e); + console.error('Error auto-publishing story', e); + } + + try { + if (hasSubmittedBefore) { + await sendSubmittedAgainEmail(story, isAutoPublished); + } else { + await sendSubmittedEmail(story, isAutoPublished); + } + } catch (e) { + console.error('Error sending story submitted email', e); } } @@ -70,7 +94,7 @@ async function onStoryPublished(storyId: Story['id']): Promise { try { // If we did not send submitted email before, do it now (will occur during rollout of email feature) if (!story.lastEmailMessageId) { - await sendSubmittedEmail(story); + await sendSubmittedEmail(story, false); } await sendPublishedEmail(story); } catch (e) { @@ -120,7 +144,7 @@ export async function backfillUserStoryEmails(): Promise { } try { - await sendSubmittedEmail(story); + await sendSubmittedEmail(story, false); await StoryRepository().update(story.id, { hasEverSubmitted: true, }); diff --git a/backend/src/business/stories/StoryUserEmailService.ts b/backend/src/business/stories/StoryUserEmailService.ts index 974e0350..e81160ce 100644 --- a/backend/src/business/stories/StoryUserEmailService.ts +++ b/backend/src/business/stories/StoryUserEmailService.ts @@ -70,7 +70,10 @@ function forgeImageThumbnailUrl(photo: Story['photo']): string { return `https://photos.1940s.nyc/420-jpg/${photo.identifier}.jpg`; } -function forgeStoryTemplateContext(story: Story): StoryEmailTemplateData { +function forgeStoryTemplateContext( + story: Story, + isPublished: boolean +): StoryEmailTemplateData { return { storytellerName: required(story.storytellerName, 'storytellerName'), photoDescription: describePhoto(story.photo), @@ -79,6 +82,7 @@ function forgeStoryTemplateContext(story: Story): StoryEmailTemplateData { photoThumbnailUrl: forgeImageThumbnailUrl(story.photo), mapImageUrl: null, mapImageUrlRetina: null, + isPublished, }; } @@ -90,7 +94,8 @@ function forgeStoryMetadata(story: Story): StoryEmailMetadata { async function sendStoryUserEmail( story: Story, - Template: EmailTemplate + Template: EmailTemplate, + isPublished: boolean ): Promise { if (!story.photo) { throw new Error('Expected photo to be resolved'); @@ -98,7 +103,7 @@ async function sendStoryUserEmail( const email: TemplatedEmailData = Template.createTemplatedEmail({ to: required(story.storytellerEmail, 'storytellerEmail'), - templateContext: forgeStoryTemplateContext(story), + templateContext: forgeStoryTemplateContext(story, isPublished), metadata: forgeStoryMetadata(story), referenceMessageId: story.lastEmailMessageId ?? undefined, }); @@ -109,18 +114,24 @@ async function sendStoryUserEmail( await StoryRepository().update(story.id, { lastEmailMessageId: messageId }); } -export async function sendSubmittedEmail(story: Story): Promise { - return sendStoryUserEmail(story, StorySubmittedTemplate); +export async function sendSubmittedEmail( + story: Story, + isPublished: boolean +): Promise { + return sendStoryUserEmail(story, StorySubmittedTemplate, isPublished); } -export async function sendSubmittedAgainEmail(story: Story): Promise { - return sendStoryUserEmail(story, StorySubmittedAgainTemplate); +export async function sendSubmittedAgainEmail( + story: Story, + isPublished: boolean +): Promise { + return sendStoryUserEmail(story, StorySubmittedAgainTemplate, isPublished); } export async function sendPublishedEmail(story: Story): Promise { - return sendStoryUserEmail(story, StoryPublishedTemplate); + return sendStoryUserEmail(story, StoryPublishedTemplate, true); } export async function sendUserRemovedEmail(story: Story): Promise { - return sendStoryUserEmail(story, StoryUserRemovedTemplate); + return sendStoryUserEmail(story, StoryUserRemovedTemplate, false); }