From ef57381257e64aa13eee1d566f941a9a9f278b93 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Tue, 22 Sep 2026 17:48:39 -0400 Subject: [PATCH 1/3] Reword the moderation rules as statements, not decisions Each rule now says what is true of a submission ("this submission is an advertisement") instead of what to do about it ("this submission should be rejected because..."). Approve and reject are this app's concepts, not the model's. Within each rule, all the true cases come before all the false cases rather than alternating. Lowers MIN_REJECT_PROBABILITY from 0.375 to 0.3 to match the new scores. On a balanced 300-story sample this auto-publishes 127 of 150 human-approved stories, up from 112, while letting through the same number of stories a human rejected. The admin flag badge threshold is unchanged. Co-Authored-By: Claude Opus 5 --- backend/moderation-experiment/README.md | 10 +++++ backend/moderation-experiment/rules.ts | 44 ++++++++++--------- .../business/moderation/moderationRules.ts | 34 ++++++++++---- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/backend/moderation-experiment/README.md b/backend/moderation-experiment/README.md index 34bab832..e2bc35b6 100644 --- a/backend/moderation-experiment/README.md +++ b/backend/moderation-experiment/README.md @@ -16,6 +16,16 @@ Both backends read their rules and prompt text from [`rules.ts`](./rules.ts). Th Both backends use the official [`@openrouter/sdk`](https://www.npmjs.com/package/@openrouter/sdk) package, not raw HTTP calls, so requests and responses are typed and validated. +## How to word a rule + +Write each rule as a statement about the submission, not as an instruction to a moderator. Say "this submission is an advertisement," never "this submission should be rejected because it is an advertisement." Approve and reject are this app's concepts. Jev does not know them. It is only asked how likely the statement is to be true, and the backend decides what to do with the answer. + +Within a rule, put every case that makes it true first, then every case that makes it false. Do not alternate. + +Both conventions were measured against 300 real stories. Together they took the auto-approve rate from 112 of 150 to 127 of 150 at the same rate of letting bad stories through. + +`rules.ts` is a copy of the production rules in `src/business/moderation/moderationRules.ts`, kept in sync by hand. That is on purpose: tuning here should not silently change what the live site does. When a change here proves out, copy it across and move `MIN_REJECT_PROBABILITY` to match `REJECT_THRESHOLD`. + ## Running it ```bash diff --git a/backend/moderation-experiment/rules.ts b/backend/moderation-experiment/rules.ts index c5d9954b..5b683e88 100644 --- a/backend/moderation-experiment/rules.ts +++ b/backend/moderation-experiment/rules.ts @@ -3,20 +3,22 @@ import { RuleKey } from './types'; /** * THE PROMPT. * - * Each entry is a yes/no ("noul") question: the probability that a submission - * should be REJECTED for that reason. Edit instructions/criteria here to - * iterate — both moderators/jevModerator.ts and moderators/llmModerator.ts - * read this same definition, so a change here changes both backends - * identically. + * Each entry is a yes/no ("noul") question about a property of the + * submission. Keep approve/reject vocabulary out of these strings, and put + * every true case before every false case -- see README.md, "How to word a + * rule". Edit instructions/criteria here to iterate; both + * moderators/jevModerator.ts and moderators/llmModerator.ts read this same + * definition. * * Combine logic: reject if ANY rule's probability >= REJECT_THRESHOLD. * approveProbability = 1 - max(rule probabilities). + * + * Mirrors src/business/moderation/moderationRules.ts. Keep both in sync. */ // Below neutral (0.5) on purpose: false-approves are costly, false-rejects -// just go to human review. See analysis/generate-threshold-chart.mjs to -// re-tune. 0.375 is a conservative starting point; raise toward 0.45-0.475 -// once its false-approve rate is confirmed safe in production. -export const REJECT_THRESHOLD = 0.375; +// just go to human review. Flat from ~0.26 to ~0.42 on the balanced 300-story +// sample; re-sweep with the viewer's threshold tab. +export const REJECT_THRESHOLD = 0.3; export interface RuleDefinition { instructions: string; @@ -26,7 +28,7 @@ export interface RuleDefinition { export const RULES: Record = { linkOrAd: { instructions: - 'This submission should be rejected because it primarily links away from the site or advertises something, rather than information. A submission that shares real historical or informative content and includes a URL only as a supporting citation (a source for further reading, e.g. a Wikipedia page, news article, or old listing) is NOT an ad -- that is normal, encouraged sourcing. Only reject for this rule when the link/URL itself is the actual point of the submission, or the text is promotional copy for a current business (hours, deals, "visit us"), rather than genuine information about the place.', + 'This submission is primarily a link away from the site or an advertisement for something, rather than information. A submission that shares real historical or informative content and includes a URL only as a supporting citation (a source for further reading, e.g. a Wikipedia page, news article, or old listing) is NOT an ad -- that is normal, encouraged sourcing. This is true only when the link/URL itself is the actual point of the submission, or the text is promotional copy for a current business (hours, deals, "visit us"), rather than genuine information about the place.', criteria: { true: 'The main point is a link, URL, current business promotion, or advertisement (e.g. "check out our new menu, link below") rather than history or information -- remove the link and there is nothing informative left.', false: @@ -35,7 +37,7 @@ export const RULES: Record = { }, complaintOrCorrection: { instructions: - 'This submission should be rejected because its point is to correct or complain about the site\'s existing data (the pin, address, photo placement, or a caption) rather than to tell a story -- even if it also includes some supporting detail explaining the correction. Also reject a note explicitly addressed to site staff/the webmaster (e.g. "please redact this, I\'m including this for the webmaster", "please disregard my previous post"). This is different from simply mentioning a date or how long something has been true, which is not a correction.', + 'This submission\'s point is to correct or complain about the site\'s existing data (the pin, address, photo placement, or a caption) rather than to tell a story -- even if it also includes some supporting detail explaining the correction. This is also true of a note explicitly addressed to site staff/the webmaster (e.g. "please redact this, I\'m including this for the webmaster", "please disregard my previous post"). This is different from simply mentioning a date or how long something has been true, which is not a correction.', criteria: { true: 'The submission\'s point is that existing information (the pin location, address, a caption, or a previous post) is wrong and should be fixed -- e.g. "This house is actually on Mosholu Ave, it\'s 5701 Mosholu Ave", "The address on the map is WRONG, this is 14th Ave..." -- even if it explains why or adds a supporting detail. Also true for a complaint about the website/app/photo quality, a direct request to add/change specific information, a note addressed to site staff/the webmaster, or a submission that is ONLY a bare question/uncertain guess with no actual place information given (e.g. "does anyone know what happened to...?", "Maxie\'s?"). This does NOT apply to a submission that shares real content (a family name, a memory, a specific detail) even if phrased with some uncertainty or as a search/inquiry (e.g. "I think my uncle and aunt lived in this house with my cousins" is a family story, not a bare question, even though it includes "I think").', false: @@ -44,7 +46,7 @@ export const RULES: Record = { }, nonsense: { instructions: - 'This submission should be rejected because it is nonsense, gibberish, or not a coherent story.', + 'This submission is nonsense, gibberish, or not a coherent story.', criteria: { true: 'The text is gibberish, random characters/words, test input, or otherwise incoherent.', false: @@ -53,16 +55,16 @@ export const RULES: Record = { }, addressOnly: { instructions: - 'These are captions on OLD (1940s-era) photos. A bare business/institution name ALONE, with no address attached (e.g. "Bayard-Condict Building", "PS120Q Elementary School", "St Anthony\'s Church"), should be read by default as identifying what was THERE IN THE PHOTO -- i.e. history -- and approved. But a submission built around a raw street address with a business/occupant name tacked on in a telegraphic, directory-listing style (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "600 Pelham Pkwy South. Citgo Gas Station") reads as a current listing, not history, and should be rejected even with no explicit "now" -- addresses are how you\'d look up what\'s there TODAY, not a historical reference. Also reject when the submission explicitly marks itself as being about the PRESENT DAY with nothing more (e.g. "This is currently a Chick-Fil-A"), a bare current address alone, a physical/construction status change alone (demolished, renovated, rebuilt), or the author baldly stating they themself live/work there with no other detail (e.g. "I live here", "my home!", "I own this property"). Any real history, personal anecdote, date, family/person reference, or other detail beyond a bare current-day statement should be approved.', + 'These are captions on OLD (1940s-era) photos. This is true when the submission is about the PRESENT DAY and nothing more. It is TRUE of a submission built around a raw street address with a business/occupant name tacked on in a telegraphic, directory-listing style (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "600 Pelham Pkwy South. Citgo Gas Station"), which reads as a current listing, not history, even with no explicit "now" -- addresses are how you\'d look up what\'s there TODAY, not a historical reference. It is also TRUE when the submission explicitly marks itself as being about the present day with nothing more (e.g. "This is currently a Chick-Fil-A"), a bare current address alone, a physical/construction status change alone (demolished, renovated, rebuilt), or the author baldly stating they themself live/work there with no other detail (e.g. "I live here", "my home!", "I own this property"). It is FALSE of a bare business/institution name ALONE, with no address attached (e.g. "Bayard-Condict Building", "PS120Q Elementary School", "St Anthony\'s Church"), which should be read by default as identifying what was THERE IN THE PHOTO -- i.e. history. It is also FALSE whenever any real history, personal anecdote, date, family/person reference, or other detail beyond a bare current-day statement is present.', criteria: { - true: 'The submission is a raw street address paired with a CURRENT COMMERCIAL business name (a restaurant, store, gas station, generic retail) in telegraphic, directory-style phrasing and NOTHING ELSE beyond that (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "2000 White Plains, A to Z Autosound") -- this does NOT apply if there is also a real biographical/historical detail attached (a nickname, notable person, organization, or story), even alongside an address, and does NOT apply to a public/government institution (a police/fire precinct, public school, public library, public housing project), which is worth naming for its own sake even next to an address or "now"/"currently" -- this leeway does NOT extend to a church, congregation, or other religious institution, which needs the same real detail as a commercial business to be approved; OR explicitly frames itself as being about the present day with nothing else (a COMMERCIAL business/occupant marked "now"/"currently"/"today" with no further elaboration, a bare current address with no name, a physical/construction status change alone with no names, or the author baldly stating they themself live/lived/grew up there with no other story, e.g. "I live here", "my home!", "I own this property", "Grew up here", "Childhood home") -- a bare residency claim is not a story even with an emotional reaction to the photo comparison tacked on ("so cool", "much nicer looking", "nifty"). An explicit "was X" / "used to be X" naming a former identity is history, not current-day, even if an address is also given.', + true: 'The submission is a raw street address paired with a CURRENT COMMERCIAL business name (a restaurant, store, gas station, generic retail) in telegraphic, directory-style phrasing and NOTHING ELSE beyond that (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "2000 White Plains, A to Z Autosound") -- this does NOT apply if there is also a real biographical/historical detail attached (a nickname, notable person, organization, or story), even alongside an address, and does NOT apply to a public/government institution (a police/fire precinct, public school, public library, public housing project), which is worth naming for its own sake even next to an address or "now"/"currently" -- this leeway does NOT extend to a church, congregation, or other religious institution, which needs the same real detail as a commercial business for this to be false; OR explicitly frames itself as being about the present day with nothing else (a COMMERCIAL business/occupant marked "now"/"currently"/"today" with no further elaboration, a bare current address with no name, a physical/construction status change alone with no names, or the author baldly stating they themself live/lived/grew up there with no other story, e.g. "I live here", "my home!", "I own this property", "Grew up here", "Childhood home") -- a bare residency claim is not a story even with an emotional reaction to the photo comparison tacked on ("so cool", "much nicer looking", "nifty"). An explicit "was X" / "used to be X" naming a former identity is history, not current-day, even if an address is also given.', false: - 'The submission names a business/institution/building BY ITSELF, with no address attached and no explicit "now/currently" framing (read by default as identifying what was in the old photo, e.g. "Bayard-Condict Building", "New York Coliseum", "St. Gabriel\'s Episcopal Church") -- OR names a public/government institution (police/fire precinct, public school, public library, public housing project) even with an address or "now/currently" framing (e.g. "NYPD 77 Precinct 127 Utica Ave") -- but NOT a church/congregation/religious institution, which still needs real detail beyond the bare name+address to be approved -- OR explicitly describes the past/history (e.g. "was a Jewish Synagogue, now Salem Church"), gives a specific date or time period, names a past resident, family, or notable person connected to the place, notes it was a filming location for a named production, makes a superlative claim (oldest, first, last), mentions a nickname/reputation, or shares a real personal anecdote/detail beyond a bare current-day statement.', + 'The submission names a business/institution/building BY ITSELF, with no address attached and no explicit "now/currently" framing (read by default as identifying what was in the old photo, e.g. "Bayard-Condict Building", "New York Coliseum", "St. Gabriel\'s Episcopal Church") -- OR names a public/government institution (police/fire precinct, public school, public library, public housing project) even with an address or "now/currently" framing (e.g. "NYPD 77 Precinct 127 Utica Ave") -- but NOT a church/congregation/religious institution, which still needs real detail beyond the bare name+address for this to be false -- OR explicitly describes the past/history (e.g. "was a Jewish Synagogue, now Salem Church"), gives a specific date or time period, names a past resident, family, or notable person connected to the place, notes it was a filming location for a named production, makes a superlative claim (oldest, first, last), mentions a nickname/reputation, or shares a real personal anecdote/detail beyond a bare current-day statement.', }, }, offensive: { instructions: - 'This submission should be rejected because it contains explicit offensive, sexual, racist, or graphically violent/disturbing content -- not because the history it describes is sad or the neighborhood had a rough reputation.', + 'This submission contains explicit offensive, sexual, racist, or graphically violent/disturbing content -- this is not true merely because the history it describes is sad or the neighborhood had a rough reputation.', criteria: { true: 'The text contains explicit sexual content, racist language or slurs, or a first-person or otherwise personal, visceral account of violent trauma (e.g. "I was shot", a pet killed in front of the author) that centers the shock/harm itself -- NOT a distanced historical or factual account of a notable crime, assassination, or death recounted as local history/trivia (e.g. a mob figure\'s assassination decades ago).', false: @@ -71,7 +73,7 @@ export const RULES: Record = { }, trolling: { instructions: - 'This submission should be rejected because it is trolling: a bad-faith joke at the expense of the site/moderation process itself, or intentionally disruptive/nonsensical submission, rather than a genuine story. A story that reports a place had an unflattering nickname, bad reputation, or was disliked is NOT trolling -- that is real history, not mockery of the site.', + 'This submission is trolling: a bad-faith joke at the expense of the site/submission process itself, or an intentionally disruptive/nonsensical submission, rather than a genuine story. A story that reports a place had an unflattering nickname, bad reputation, or was disliked is NOT trolling -- that is real history, not mockery of the site.', criteria: { true: 'The submission itself is a joke aimed at the site, the submission form, or the moderator (e.g. testing the system, mocking the concept of the site), or is otherwise a bad-faith, intentionally disruptive stunt rather than an attempt at a real story.', false: @@ -131,11 +133,11 @@ export function buildLlmSystemPrompt(): string { ).join('\n'); return [ - 'You are moderating user-submitted stories for a neighborhood history website.', - 'You will be given the submission as a JSON object. For each rejection rule below, output the probability (0 to 1) that the rule applies to this submission — i.e. the probability that a human moderator would reject it for that specific reason.', - 'A probability near 1 means the rule clearly applies (reject for this reason). A probability near 0 means it clearly does not apply.', + 'You are reading user-submitted stories about places in New York City.', + 'You will be given the submission as a JSON object. For each statement below, output the probability (0 to 1) that the statement is true of this submission.', + 'A probability near 1 means the statement is clearly true of it. A probability near 0 means it is clearly false.', '', - 'Rules:', + 'Statements:', ruleList, '', 'Respond with only the JSON object matching the given schema — one number per rule, no other text.', diff --git a/backend/src/business/moderation/moderationRules.ts b/backend/src/business/moderation/moderationRules.ts index 5637bfd6..c9a4d8a6 100644 --- a/backend/src/business/moderation/moderationRules.ts +++ b/backend/src/business/moderation/moderationRules.ts @@ -6,8 +6,13 @@ export type AiModerationFlag = | 'offensive' | 'trolling'; -export const MIN_REJECT_PROBABILITY = 0.375; +// Below neutral (0.5) on purpose: auto-publishing something a human would +// have rejected is costly, while a false reject just waits for review. +// Re-tune with moderation-experiment. +export const MIN_REJECT_PROBABILITY = 0.3; +// Separate, higher bar for showing a flag badge on the admin review screen. +// Advisory only -- it never auto-publishes or auto-rejects anything. export const MIN_FLAG_PROBABILITY = 0.5; export interface RuleDefinition { @@ -15,10 +20,21 @@ export interface RuleDefinition { criteria: { true: string; false: string }; } +/** + * THE PROMPT. + * + * Each entry is a yes/no ("noul") question about a property of the + * submission. Keep approve/reject vocabulary out of these strings, and put + * every true case before every false case -- see moderation-experiment's + * README, "How to word a rule". + * + * Mirrors moderation-experiment/rules.ts, where changes are measured before + * they land here. Keep both in sync. + */ export const RULES: Record = { linkOrAd: { instructions: - 'This submission should be rejected because it primarily links away from the site or advertises something, rather than information. A submission that shares real historical or informative content and includes a URL only as a supporting citation (a source for further reading, e.g. a Wikipedia page, news article, or old listing) is NOT an ad -- that is normal, encouraged sourcing. Only reject for this rule when the link/URL itself is the actual point of the submission, or the text is promotional copy for a current business (hours, deals, "visit us"), rather than genuine information about the place.', + 'This submission is primarily a link away from the site or an advertisement for something, rather than information. A submission that shares real historical or informative content and includes a URL only as a supporting citation (a source for further reading, e.g. a Wikipedia page, news article, or old listing) is NOT an ad -- that is normal, encouraged sourcing. This is true only when the link/URL itself is the actual point of the submission, or the text is promotional copy for a current business (hours, deals, "visit us"), rather than genuine information about the place.', criteria: { true: 'The main point is a link, URL, current business promotion, or advertisement (e.g. "check out our new menu, link below") rather than history or information -- remove the link and there is nothing informative left.', false: @@ -27,7 +43,7 @@ export const RULES: Record = { }, complaintOrCorrection: { instructions: - 'This submission should be rejected because its point is to correct or complain about the site\'s existing data (the pin, address, photo placement, or a caption) rather than to tell a story -- even if it also includes some supporting detail explaining the correction. Also reject a note explicitly addressed to site staff/the webmaster (e.g. "please redact this, I\'m including this for the webmaster", "please disregard my previous post"). This is different from simply mentioning a date or how long something has been true, which is not a correction.', + 'This submission\'s point is to correct or complain about the site\'s existing data (the pin, address, photo placement, or a caption) rather than to tell a story -- even if it also includes some supporting detail explaining the correction. This is also true of a note explicitly addressed to site staff/the webmaster (e.g. "please redact this, I\'m including this for the webmaster", "please disregard my previous post"). This is different from simply mentioning a date or how long something has been true, which is not a correction.', criteria: { true: 'The submission\'s point is that existing information (the pin location, address, a caption, or a previous post) is wrong and should be fixed -- e.g. "This house is actually on Mosholu Ave, it\'s 5701 Mosholu Ave", "The address on the map is WRONG, this is 14th Ave..." -- even if it explains why or adds a supporting detail. Also true for a complaint about the website/app/photo quality, a direct request to add/change specific information, a note addressed to site staff/the webmaster, or a submission that is ONLY a bare question/uncertain guess with no actual place information given (e.g. "does anyone know what happened to...?", "Maxie\'s?"). This does NOT apply to a submission that shares real content (a family name, a memory, a specific detail) even if phrased with some uncertainty or as a search/inquiry (e.g. "I think my uncle and aunt lived in this house with my cousins" is a family story, not a bare question, even though it includes "I think").', false: @@ -36,7 +52,7 @@ export const RULES: Record = { }, nonsense: { instructions: - 'This submission should be rejected because it is nonsense, gibberish, or not a coherent story.', + 'This submission is nonsense, gibberish, or not a coherent story.', criteria: { true: 'The text is gibberish, random characters/words, test input, or otherwise incoherent.', false: @@ -45,16 +61,16 @@ export const RULES: Record = { }, addressOnly: { instructions: - 'These are captions on OLD (1940s-era) photos. A bare business/institution name ALONE, with no address attached (e.g. "Bayard-Condict Building", "PS120Q Elementary School", "St Anthony\'s Church"), should be read by default as identifying what was THERE IN THE PHOTO -- i.e. history -- and approved. But a submission built around a raw street address with a business/occupant name tacked on in a telegraphic, directory-listing style (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "600 Pelham Pkwy South. Citgo Gas Station") reads as a current listing, not history, and should be rejected even with no explicit "now" -- addresses are how you\'d look up what\'s there TODAY, not a historical reference. Also reject when the submission explicitly marks itself as being about the PRESENT DAY with nothing more (e.g. "This is currently a Chick-Fil-A"), a bare current address alone, a physical/construction status change alone (demolished, renovated, rebuilt), or the author baldly stating they themself live/work there with no other detail (e.g. "I live here", "my home!", "I own this property"). Any real history, personal anecdote, date, family/person reference, or other detail beyond a bare current-day statement should be approved.', + 'These are captions on OLD (1940s-era) photos. This is true when the submission is about the PRESENT DAY and nothing more. It is TRUE of a submission built around a raw street address with a business/occupant name tacked on in a telegraphic, directory-listing style (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "600 Pelham Pkwy South. Citgo Gas Station"), which reads as a current listing, not history, even with no explicit "now" -- addresses are how you\'d look up what\'s there TODAY, not a historical reference. It is also TRUE when the submission explicitly marks itself as being about the present day with nothing more (e.g. "This is currently a Chick-Fil-A"), a bare current address alone, a physical/construction status change alone (demolished, renovated, rebuilt), or the author baldly stating they themself live/work there with no other detail (e.g. "I live here", "my home!", "I own this property"). It is FALSE of a bare business/institution name ALONE, with no address attached (e.g. "Bayard-Condict Building", "PS120Q Elementary School", "St Anthony\'s Church"), which should be read by default as identifying what was THERE IN THE PHOTO -- i.e. history. It is also FALSE whenever any real history, personal anecdote, date, family/person reference, or other detail beyond a bare current-day statement is present.', criteria: { - true: 'The submission is a raw street address paired with a CURRENT COMMERCIAL business name (a restaurant, store, gas station, generic retail) in telegraphic, directory-style phrasing and NOTHING ELSE beyond that (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "2000 White Plains, A to Z Autosound") -- this does NOT apply if there is also a real biographical/historical detail attached (a nickname, notable person, organization, or story), even alongside an address, and does NOT apply to a public/government institution (a police/fire precinct, public school, public library, public housing project), which is worth naming for its own sake even next to an address or "now"/"currently" -- this leeway does NOT extend to a church, congregation, or other religious institution, which needs the same real detail as a commercial business to be approved; OR explicitly frames itself as being about the present day with nothing else (a COMMERCIAL business/occupant marked "now"/"currently"/"today" with no further elaboration, a bare current address with no name, a physical/construction status change alone with no names, or the author baldly stating they themself live/lived/grew up there with no other story, e.g. "I live here", "my home!", "I own this property", "Grew up here", "Childhood home") -- a bare residency claim is not a story even with an emotional reaction to the photo comparison tacked on ("so cool", "much nicer looking", "nifty"). An explicit "was X" / "used to be X" naming a former identity is history, not current-day, even if an address is also given.', + true: 'The submission is a raw street address paired with a CURRENT COMMERCIAL business name (a restaurant, store, gas station, generic retail) in telegraphic, directory-style phrasing and NOTHING ELSE beyond that (e.g. "1951 Southern Blvd, Roy\'s Restaurant and Bar", "2000 White Plains, A to Z Autosound") -- this does NOT apply if there is also a real biographical/historical detail attached (a nickname, notable person, organization, or story), even alongside an address, and does NOT apply to a public/government institution (a police/fire precinct, public school, public library, public housing project), which is worth naming for its own sake even next to an address or "now"/"currently" -- this leeway does NOT extend to a church, congregation, or other religious institution, which needs the same real detail as a commercial business for this to be false; OR explicitly frames itself as being about the present day with nothing else (a COMMERCIAL business/occupant marked "now"/"currently"/"today" with no further elaboration, a bare current address with no name, a physical/construction status change alone with no names, or the author baldly stating they themself live/lived/grew up there with no other story, e.g. "I live here", "my home!", "I own this property", "Grew up here", "Childhood home") -- a bare residency claim is not a story even with an emotional reaction to the photo comparison tacked on ("so cool", "much nicer looking", "nifty"). An explicit "was X" / "used to be X" naming a former identity is history, not current-day, even if an address is also given.', false: - 'The submission names a business/institution/building BY ITSELF, with no address attached and no explicit "now/currently" framing (read by default as identifying what was in the old photo, e.g. "Bayard-Condict Building", "New York Coliseum", "St. Gabriel\'s Episcopal Church") -- OR names a public/government institution (police/fire precinct, public school, public library, public housing project) even with an address or "now/currently" framing (e.g. "NYPD 77 Precinct 127 Utica Ave") -- but NOT a church/congregation/religious institution, which still needs real detail beyond the bare name+address to be approved -- OR explicitly describes the past/history (e.g. "was a Jewish Synagogue, now Salem Church"), gives a specific date or time period, names a past resident, family, or notable person connected to the place, notes it was a filming location for a named production, makes a superlative claim (oldest, first, last), mentions a nickname/reputation, or shares a real personal anecdote/detail beyond a bare current-day statement.', + 'The submission names a business/institution/building BY ITSELF, with no address attached and no explicit "now/currently" framing (read by default as identifying what was in the old photo, e.g. "Bayard-Condict Building", "New York Coliseum", "St. Gabriel\'s Episcopal Church") -- OR names a public/government institution (police/fire precinct, public school, public library, public housing project) even with an address or "now/currently" framing (e.g. "NYPD 77 Precinct 127 Utica Ave") -- but NOT a church/congregation/religious institution, which still needs real detail beyond the bare name+address for this to be false -- OR explicitly describes the past/history (e.g. "was a Jewish Synagogue, now Salem Church"), gives a specific date or time period, names a past resident, family, or notable person connected to the place, notes it was a filming location for a named production, makes a superlative claim (oldest, first, last), mentions a nickname/reputation, or shares a real personal anecdote/detail beyond a bare current-day statement.', }, }, offensive: { instructions: - 'This submission should be rejected because it contains explicit offensive, sexual, racist, or graphically violent/disturbing content -- not because the history it describes is sad or the neighborhood had a rough reputation.', + 'This submission contains explicit offensive, sexual, racist, or graphically violent/disturbing content -- this is not true merely because the history it describes is sad or the neighborhood had a rough reputation.', criteria: { true: 'The text contains explicit sexual content, racist language or slurs, or a first-person or otherwise personal, visceral account of violent trauma (e.g. "I was shot", a pet killed in front of the author) that centers the shock/harm itself -- NOT a distanced historical or factual account of a notable crime, assassination, or death recounted as local history/trivia (e.g. a mob figure\'s assassination decades ago).', false: @@ -63,7 +79,7 @@ export const RULES: Record = { }, trolling: { instructions: - 'This submission should be rejected because it is trolling: a bad-faith joke at the expense of the site/moderation process itself, or intentionally disruptive/nonsensical submission, rather than a genuine story. A story that reports a place had an unflattering nickname, bad reputation, or was disliked is NOT trolling -- that is real history, not mockery of the site.', + 'This submission is trolling: a bad-faith joke at the expense of the site/submission process itself, or an intentionally disruptive/nonsensical submission, rather than a genuine story. A story that reports a place had an unflattering nickname, bad reputation, or was disliked is NOT trolling -- that is real history, not mockery of the site.', criteria: { true: 'The submission itself is a joke aimed at the site, the submission form, or the moderator (e.g. testing the system, mocking the concept of the site), or is otherwise a bad-faith, intentionally disruptive stunt rather than an attempt at a real story.', false: From 099dc2be4e9ac483945c2efe3ec782cf04dc0713 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Tue, 22 Sep 2026 17:53:09 -0400 Subject: [PATCH 2/3] Drop the rule-wording section and threshold measurements Co-Authored-By: Claude Opus 5 --- backend/moderation-experiment/README.md | 12 ++---------- backend/moderation-experiment/rules.ts | 9 +++------ backend/src/business/moderation/moderationRules.ts | 7 ++----- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/backend/moderation-experiment/README.md b/backend/moderation-experiment/README.md index e2bc35b6..eb09deef 100644 --- a/backend/moderation-experiment/README.md +++ b/backend/moderation-experiment/README.md @@ -14,17 +14,9 @@ The tool scores a story two ways, against the same rules. Both backends read their rules and prompt text from [`rules.ts`](./rules.ts). That is the only file that holds prompt content. The moderator files (`moderators/jevModerator.ts` and `moderators/llmModerator.ts`) only handle the API call and response parsing. Edit the rule instructions, criteria, or the reject threshold in `rules.ts`, and both backends pick up the change. -Both backends use the official [`@openrouter/sdk`](https://www.npmjs.com/package/@openrouter/sdk) package, not raw HTTP calls, so requests and responses are typed and validated. - -## How to word a rule - -Write each rule as a statement about the submission, not as an instruction to a moderator. Say "this submission is an advertisement," never "this submission should be rejected because it is an advertisement." Approve and reject are this app's concepts. Jev does not know them. It is only asked how likely the statement is to be true, and the backend decides what to do with the answer. +`rules.ts` is a hand-synced copy of the production rules in `../src/business/moderation/moderationRules.ts`, so that tuning here does not change the live site on its own. Once a change proves out, copy it across and set `MIN_REJECT_PROBABILITY` to match `REJECT_THRESHOLD`. -Within a rule, put every case that makes it true first, then every case that makes it false. Do not alternate. - -Both conventions were measured against 300 real stories. Together they took the auto-approve rate from 112 of 150 to 127 of 150 at the same rate of letting bad stories through. - -`rules.ts` is a copy of the production rules in `src/business/moderation/moderationRules.ts`, kept in sync by hand. That is on purpose: tuning here should not silently change what the live site does. When a change here proves out, copy it across and move `MIN_REJECT_PROBABILITY` to match `REJECT_THRESHOLD`. +Both backends use the official [`@openrouter/sdk`](https://www.npmjs.com/package/@openrouter/sdk) package, not raw HTTP calls, so requests and responses are typed and validated. ## Running it diff --git a/backend/moderation-experiment/rules.ts b/backend/moderation-experiment/rules.ts index 5b683e88..8e1104d3 100644 --- a/backend/moderation-experiment/rules.ts +++ b/backend/moderation-experiment/rules.ts @@ -4,11 +4,9 @@ import { RuleKey } from './types'; * THE PROMPT. * * Each entry is a yes/no ("noul") question about a property of the - * submission. Keep approve/reject vocabulary out of these strings, and put - * every true case before every false case -- see README.md, "How to word a - * rule". Edit instructions/criteria here to iterate; both + * submission. Edit instructions/criteria here to iterate -- both * moderators/jevModerator.ts and moderators/llmModerator.ts read this same - * definition. + * definition, so a change here changes both backends identically. * * Combine logic: reject if ANY rule's probability >= REJECT_THRESHOLD. * approveProbability = 1 - max(rule probabilities). @@ -16,8 +14,7 @@ import { RuleKey } from './types'; * Mirrors src/business/moderation/moderationRules.ts. Keep both in sync. */ // Below neutral (0.5) on purpose: false-approves are costly, false-rejects -// just go to human review. Flat from ~0.26 to ~0.42 on the balanced 300-story -// sample; re-sweep with the viewer's threshold tab. +// just go to human review. Re-sweep with the viewer's threshold tab. export const REJECT_THRESHOLD = 0.3; export interface RuleDefinition { diff --git a/backend/src/business/moderation/moderationRules.ts b/backend/src/business/moderation/moderationRules.ts index c9a4d8a6..032efd8d 100644 --- a/backend/src/business/moderation/moderationRules.ts +++ b/backend/src/business/moderation/moderationRules.ts @@ -24,12 +24,9 @@ export interface RuleDefinition { * THE PROMPT. * * Each entry is a yes/no ("noul") question about a property of the - * submission. Keep approve/reject vocabulary out of these strings, and put - * every true case before every false case -- see moderation-experiment's - * README, "How to word a rule". + * submission. * - * Mirrors moderation-experiment/rules.ts, where changes are measured before - * they land here. Keep both in sync. + * Mirrors moderation-experiment/rules.ts. Keep both in sync. */ export const RULES: Record = { linkOrAd: { From beb2167b9156f4c8e9db019f58b6f06d8e67e6bf Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Tue, 22 Sep 2026 17:54:17 -0400 Subject: [PATCH 3/3] Remove the RULES doc block from the production rules Co-Authored-By: Claude Opus 5 --- backend/src/business/moderation/moderationRules.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/backend/src/business/moderation/moderationRules.ts b/backend/src/business/moderation/moderationRules.ts index 032efd8d..a67bfc1b 100644 --- a/backend/src/business/moderation/moderationRules.ts +++ b/backend/src/business/moderation/moderationRules.ts @@ -20,14 +20,6 @@ export interface RuleDefinition { criteria: { true: string; false: string }; } -/** - * THE PROMPT. - * - * Each entry is a yes/no ("noul") question about a property of the - * submission. - * - * Mirrors moderation-experiment/rules.ts. Keep both in sync. - */ export const RULES: Record = { linkOrAd: { instructions: