From 0f69dd4546aee0d2ffe9a42f58460f26254df437 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Sat, 19 Sep 2026 15:10:57 -0400 Subject: [PATCH 1/5] Auto-publish stories the AI moderator is confident about A submitted story that isn't from a banned user is now auto-published when its AI moderation score clears MIN_REJECT_PROBABILITY, the same way a banned user's story is auto-rejected: state set to published, lastReviewer set to 'system'. The storyteller gets the story-auto-published email instead of story-submitted. Co-Authored-By: Claude Sonnet 5 --- .../templates/StoryAutoPublishedTemplate.ts | 18 +++++++++ .../moderation/AiStoryModerationService.ts | 25 +++++++----- .../src/business/stories/StoriesService.ts | 39 +++++++++++++------ .../business/stories/StoryUserEmailService.ts | 5 +++ 4 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 backend/src/business/email/templates/StoryAutoPublishedTemplate.ts diff --git a/backend/src/business/email/templates/StoryAutoPublishedTemplate.ts b/backend/src/business/email/templates/StoryAutoPublishedTemplate.ts new file mode 100644 index 00000000..cb2234f7 --- /dev/null +++ b/backend/src/business/email/templates/StoryAutoPublishedTemplate.ts @@ -0,0 +1,18 @@ +import EmailTemplate from '../EmailTemplate'; +import EmailStreamType from './EmailStreamType'; +import Senders from './Senders'; +import { + StoryEmailTemplateData, + StoryEmailMetadata, +} from './StoryUserEmailTemplateData'; + +class StoryAutoPublishedTemplate extends EmailTemplate< + StoryEmailTemplateData, + StoryEmailMetadata +> { + alias = 'story-auto-published'; + from = Senders.PERSONAL; + streamType = EmailStreamType.TRANSACTIONAL; +} + +export default new StoryAutoPublishedTemplate(); diff --git a/backend/src/business/moderation/AiStoryModerationService.ts b/backend/src/business/moderation/AiStoryModerationService.ts index bac9ec91..6fbc675d 100644 --- a/backend/src/business/moderation/AiStoryModerationService.ts +++ b/backend/src/business/moderation/AiStoryModerationService.ts @@ -6,6 +6,7 @@ import { buildState, combine, AiModerationFlag, + AiStoryModerationScore, } from './moderationRules'; const MODEL = 'typesafe/jev-1.13'; @@ -13,7 +14,9 @@ const MODEL = 'typesafe/jev-1.13'; const client = new OpenRouter({ apiKey: process.env.OPENROUTER_SK }); 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 +39,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..acffe386 100644 --- a/backend/src/business/stories/StoriesService.ts +++ b/backend/src/business/stories/StoriesService.ts @@ -4,7 +4,9 @@ import User from '../../entities/User'; import StoryState from '../../enum/StoryState'; import StoryRepository from '../../repositories/StoryRepository'; import { evaluateStory } from '../moderation/AiStoryModerationService'; +import { combine } from '../moderation/moderationRules'; import { + sendAutoPublishedEmail, sendPublishedEmail, sendSubmittedAgainEmail, sendSubmittedEmail, @@ -25,28 +27,25 @@ async function onStorySubmitted(storyId: Story['id']): Promise { const story = await getStoryOrThrow(storyId, StoryState.SUBMITTED); const userRepository = AppDataSource.getRepository(User); - const hasSubmittedBefore = story.hasEverSubmitted; - - try { - if (hasSubmittedBefore) { + if (story.hasEverSubmitted) { + try { await sendSubmittedAgainEmail(story); - return; + } catch (e) { + console.error('Error sending story submitted email', e); } - await sendSubmittedEmail(story); - } catch (e) { - console.error('Error sending story submitted email', e); + return; } 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,11 +56,29 @@ async function onStorySubmitted(storyId: Story['id']): Promise { console.error('Error auto-reviewing story', e); } + let isAutoPublished = false; try { - await evaluateStory(story); + const { ruleProbabilities } = await evaluateStory(story); + if (!isUserBanned && 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); } + + try { + if (isAutoPublished) { + await sendAutoPublishedEmail(story); + } else { + await sendSubmittedEmail(story); + } + } catch (e) { + console.error('Error sending story submitted email', e); + } } async function onStoryPublished(storyId: Story['id']): Promise { diff --git a/backend/src/business/stories/StoryUserEmailService.ts b/backend/src/business/stories/StoryUserEmailService.ts index 974e0350..616be870 100644 --- a/backend/src/business/stories/StoryUserEmailService.ts +++ b/backend/src/business/stories/StoryUserEmailService.ts @@ -4,6 +4,7 @@ import Story from '../../entities/Story'; import StoryRepository from '../../repositories/StoryRepository'; import EmailService, { TemplatedEmailData } from '../email/EmailService'; import EmailTemplate from '../email/EmailTemplate'; +import StoryAutoPublishedTemplate from '../email/templates/StoryAutoPublishedTemplate'; import StoryPublishedTemplate from '../email/templates/StoryPublishedTemplate'; import StorySubmittedAgainTemplate from '../email/templates/StorySubmittedAgainTemplate'; import StorySubmittedTemplate from '../email/templates/StorySubmittedTemplate'; @@ -121,6 +122,10 @@ export async function sendPublishedEmail(story: Story): Promise { return sendStoryUserEmail(story, StoryPublishedTemplate); } +export async function sendAutoPublishedEmail(story: Story): Promise { + return sendStoryUserEmail(story, StoryAutoPublishedTemplate); +} + export async function sendUserRemovedEmail(story: Story): Promise { return sendStoryUserEmail(story, StoryUserRemovedTemplate); } From 87f75ad98ca50e8953866c2ac684a43c95b30f2e Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Sat, 19 Sep 2026 15:29:46 -0400 Subject: [PATCH 2/5] Bound the OpenRouter moderation call with a timeout Without a timeout, a hung (not just failing) OpenRouter request could stall a story submission indefinitely before the surrounding try/catch ever gets a chance to run. Co-Authored-By: Claude Sonnet 5 --- backend/src/business/moderation/AiStoryModerationService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/business/moderation/AiStoryModerationService.ts b/backend/src/business/moderation/AiStoryModerationService.ts index 6fbc675d..b3b02308 100644 --- a/backend/src/business/moderation/AiStoryModerationService.ts +++ b/backend/src/business/moderation/AiStoryModerationService.ts @@ -11,7 +11,10 @@ import { 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( From 83df23fa9a824c574e7f1b756283cc66c12de8f5 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Sat, 19 Sep 2026 15:32:54 -0400 Subject: [PATCH 3/5] Don't auto-publish stories with a low recaptcha score Reuses the same 0.7 cutoff the admin review screen already flags low-recaptcha stories with. A story below it still goes to human review even if the AI moderator would have approved it. Co-Authored-By: Claude Sonnet 5 --- backend/src/business/stories/StoriesService.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/business/stories/StoriesService.ts b/backend/src/business/stories/StoriesService.ts index acffe386..0e4e3d28 100644 --- a/backend/src/business/stories/StoriesService.ts +++ b/backend/src/business/stories/StoriesService.ts @@ -13,6 +13,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 @@ -59,7 +63,11 @@ async function onStorySubmitted(storyId: Story['id']): Promise { let isAutoPublished = false; try { const { ruleProbabilities } = await evaluateStory(story); - if (!isUserBanned && combine(ruleProbabilities).approve) { + if ( + !isUserBanned && + story.recaptchaScore >= MIN_RECAPTCHA_SCORE_TO_AUTO_PUBLISH && + combine(ruleProbabilities).approve + ) { await StoryRepository().update(story.id, { state: StoryState.PUBLISHED, lastReviewer: 'system', From 92bf780c2945c5954e08cb1a621e142090a1abc0 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Sun, 20 Sep 2026 08:55:45 -0400 Subject: [PATCH 4/5] Run moderation on every submission; only first-time can auto-publish Same bug as master: a resubmission returned early before evaluateStory ran. Fixed the same way here, and since this branch also takes action on the score, made the auto-publish eligibility explicit rather than leaving it as a side effect of the early return: only a story's first-ever submission can auto-publish. A resubmission (often following a human rejection or edit) always goes back to human review, though it's still scored and shown as flags in the review screen. Co-Authored-By: Claude Sonnet 5 --- .../src/business/stories/StoriesService.ts | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/backend/src/business/stories/StoriesService.ts b/backend/src/business/stories/StoriesService.ts index 0e4e3d28..39c5b034 100644 --- a/backend/src/business/stories/StoriesService.ts +++ b/backend/src/business/stories/StoriesService.ts @@ -31,39 +31,35 @@ async function onStorySubmitted(storyId: Story['id']): Promise { const story = await getStoryOrThrow(storyId, StoryState.SUBMITTED); const userRepository = AppDataSource.getRepository(User); - if (story.hasEverSubmitted) { - try { - await sendSubmittedAgainEmail(story); - } catch (e) { - console.error('Error sending story submitted email', e); - } - return; - } + const hasSubmittedBefore = story.hasEverSubmitted; await StoryRepository().update(story.id, { hasEverSubmitted: true, }); let isUserBanned = false; - try { - const maybeUser = await userRepository.findOneBy({ - email: story.storytellerEmail ?? '', - }); - isUserBanned = maybeUser?.isBanned ?? false; - if (isUserBanned) { - await StoryRepository().update(story.id, { - state: StoryState.REJECTED, - lastReviewer: 'system', + if (!hasSubmittedBefore) { + try { + const maybeUser = await userRepository.findOneBy({ + email: story.storytellerEmail ?? '', }); + isUserBanned = maybeUser?.isBanned ?? false; + if (isUserBanned) { + await StoryRepository().update(story.id, { + state: StoryState.REJECTED, + lastReviewer: 'system', + }); + } + } catch (e) { + console.error('Error auto-reviewing story', e); } - } catch (e) { - console.error('Error auto-reviewing story', e); } let isAutoPublished = false; try { const { ruleProbabilities } = await evaluateStory(story); if ( + !hasSubmittedBefore && !isUserBanned && story.recaptchaScore >= MIN_RECAPTCHA_SCORE_TO_AUTO_PUBLISH && combine(ruleProbabilities).approve @@ -81,6 +77,8 @@ async function onStorySubmitted(storyId: Story['id']): Promise { try { if (isAutoPublished) { await sendAutoPublishedEmail(story); + } else if (hasSubmittedBefore) { + await sendSubmittedAgainEmail(story); } else { await sendSubmittedEmail(story); } From 94d97bdf16ac6dfd49040b22c3d3ac1bf76603e3 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Sun, 20 Sep 2026 09:42:23 -0400 Subject: [PATCH 5/5] Vary the submitted emails by publish state instead of a separate template Moderation now runs on every submission, and a resubmitted story can auto-publish. That combination had no email to send. Rather than add a fourth template, we pass isPublished to story-submitted and story-submitted-again, and delete story-auto-published. Co-Authored-By: Claude Opus 5 --- .../templates/StoryAutoPublishedTemplate.ts | 18 ------- .../templates/StoryUserEmailTemplateData.ts | 1 + .../src/business/stories/StoriesService.ts | 51 ++++++++++--------- .../business/stories/StoryUserEmailService.ts | 34 ++++++++----- 4 files changed, 47 insertions(+), 57 deletions(-) delete mode 100644 backend/src/business/email/templates/StoryAutoPublishedTemplate.ts diff --git a/backend/src/business/email/templates/StoryAutoPublishedTemplate.ts b/backend/src/business/email/templates/StoryAutoPublishedTemplate.ts deleted file mode 100644 index cb2234f7..00000000 --- a/backend/src/business/email/templates/StoryAutoPublishedTemplate.ts +++ /dev/null @@ -1,18 +0,0 @@ -import EmailTemplate from '../EmailTemplate'; -import EmailStreamType from './EmailStreamType'; -import Senders from './Senders'; -import { - StoryEmailTemplateData, - StoryEmailMetadata, -} from './StoryUserEmailTemplateData'; - -class StoryAutoPublishedTemplate extends EmailTemplate< - StoryEmailTemplateData, - StoryEmailMetadata -> { - alias = 'story-auto-published'; - from = Senders.PERSONAL; - streamType = EmailStreamType.TRANSACTIONAL; -} - -export default new StoryAutoPublishedTemplate(); 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/stories/StoriesService.ts b/backend/src/business/stories/StoriesService.ts index 39c5b034..e4d9d61c 100644 --- a/backend/src/business/stories/StoriesService.ts +++ b/backend/src/business/stories/StoriesService.ts @@ -4,9 +4,8 @@ import User from '../../entities/User'; import StoryState from '../../enum/StoryState'; import StoryRepository from '../../repositories/StoryRepository'; import { evaluateStory } from '../moderation/AiStoryModerationService'; -import { combine } from '../moderation/moderationRules'; +import { AiModerationFlag, combine } from '../moderation/moderationRules'; import { - sendAutoPublishedEmail, sendPublishedEmail, sendSubmittedAgainEmail, sendSubmittedEmail, @@ -33,34 +32,38 @@ async function onStorySubmitted(storyId: Story['id']): Promise { const hasSubmittedBefore = story.hasEverSubmitted; + let ruleProbabilities: Record | null = null; + try { + ({ ruleProbabilities } = await evaluateStory(story)); + } catch (e) { + console.error('Error evaluating story with AI moderation', e); + } + await StoryRepository().update(story.id, { hasEverSubmitted: true, }); let isUserBanned = false; - if (!hasSubmittedBefore) { - try { - const maybeUser = await userRepository.findOneBy({ - email: story.storytellerEmail ?? '', + try { + const maybeUser = await userRepository.findOneBy({ + email: story.storytellerEmail ?? '', + }); + isUserBanned = maybeUser?.isBanned ?? false; + if (isUserBanned) { + await StoryRepository().update(story.id, { + state: StoryState.REJECTED, + lastReviewer: 'system', }); - isUserBanned = maybeUser?.isBanned ?? false; - if (isUserBanned) { - await StoryRepository().update(story.id, { - state: StoryState.REJECTED, - lastReviewer: 'system', - }); - } - } catch (e) { - console.error('Error auto-reviewing story', e); } + } catch (e) { + console.error('Error auto-reviewing story', e); } let isAutoPublished = false; try { - const { ruleProbabilities } = await evaluateStory(story); if ( - !hasSubmittedBefore && !isUserBanned && + ruleProbabilities && story.recaptchaScore >= MIN_RECAPTCHA_SCORE_TO_AUTO_PUBLISH && combine(ruleProbabilities).approve ) { @@ -71,16 +74,14 @@ async function onStorySubmitted(storyId: Story['id']): Promise { isAutoPublished = true; } } catch (e) { - console.error('Error evaluating story with AI moderation', e); + console.error('Error auto-publishing story', e); } try { - if (isAutoPublished) { - await sendAutoPublishedEmail(story); - } else if (hasSubmittedBefore) { - await sendSubmittedAgainEmail(story); + if (hasSubmittedBefore) { + await sendSubmittedAgainEmail(story, isAutoPublished); } else { - await sendSubmittedEmail(story); + await sendSubmittedEmail(story, isAutoPublished); } } catch (e) { console.error('Error sending story submitted email', e); @@ -93,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) { @@ -143,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 616be870..e81160ce 100644 --- a/backend/src/business/stories/StoryUserEmailService.ts +++ b/backend/src/business/stories/StoryUserEmailService.ts @@ -4,7 +4,6 @@ import Story from '../../entities/Story'; import StoryRepository from '../../repositories/StoryRepository'; import EmailService, { TemplatedEmailData } from '../email/EmailService'; import EmailTemplate from '../email/EmailTemplate'; -import StoryAutoPublishedTemplate from '../email/templates/StoryAutoPublishedTemplate'; import StoryPublishedTemplate from '../email/templates/StoryPublishedTemplate'; import StorySubmittedAgainTemplate from '../email/templates/StorySubmittedAgainTemplate'; import StorySubmittedTemplate from '../email/templates/StorySubmittedTemplate'; @@ -71,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), @@ -80,6 +82,7 @@ function forgeStoryTemplateContext(story: Story): StoryEmailTemplateData { photoThumbnailUrl: forgeImageThumbnailUrl(story.photo), mapImageUrl: null, mapImageUrlRetina: null, + isPublished, }; } @@ -91,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'); @@ -99,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, }); @@ -110,22 +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); -} - -export async function sendAutoPublishedEmail(story: Story): Promise { - return sendStoryUserEmail(story, StoryAutoPublishedTemplate); + return sendStoryUserEmail(story, StoryPublishedTemplate, true); } export async function sendUserRemovedEmail(story: Story): Promise { - return sendStoryUserEmail(story, StoryUserRemovedTemplate); + return sendStoryUserEmail(story, StoryUserRemovedTemplate, false); }