feat: three-gate auto-moderation (date/relevance/POI) + retroactive domain block#447
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…omain block Restructures news/event auto-moderation around three independent gates — Date, Relevance, and POI — auto-publishing only when all three pass, and surfacing each gate's verdict in the admin queue so a pending item shows exactly which gate needs a human. - migration 070: moderation_gates JSONB on poi_news/poi_events; settings moderation_date_floor_year (2010) and moderation_sweep_batch_size (50) - moderationService: evaluateDateGate / evaluatePoiGate / relevance gate, combine into the publish/reject/pending decision, persist moderation_gates; configurable sweep batch (was hardcoded 20); getQueue returns moderation_gates - POI gate is three-tier: about-assigned-POI -> reassign to owner/containing-boundary -> review; deny-listed POIs are filtered out of reassignment candidates so a reassignment can never land on a blocked POI - geoService: getReassignmentCandidates (owner via owner_id + smallest containing boundary POI) - relevance prompt loosened to accept on-topic evergreen content (trail/history/ destination), rejecting only off-topic or out-of-region; folds an about_poi vote - dateExtractor: search-engine date weight 3 -> 4 (proven reliable in moderation); rescore path no longer drops searchDate, so SE dates survive a rescore - filterLists: blocklist_urls is now a retroactive hard-reject deny list (was collection-time skip only) — blocking a domain cleans up already-collected items - ModerationExtras: three gate badges (pass/review/fail) with reasons - tests for the date gate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a three-gate auto-moderation system (Date, Relevance, and POI) for news and events, persisting gate verdicts in a new JSONB column and updating the admin UI to display these badges. It also introduces a configurable sweep batch size and a retroactive URL blocklist. The review feedback highlights two key improvements: addressing a potential bypass vulnerability in the URL blocklist prefix matching by enforcing domain/path boundaries, and optimizing the POI gate logic with a guard clause to prevent redundant database and LLM calls when no relevance votes are available.
| matches: (row, prefixes) => { | ||
| const norm = normalizeUrlForPrefix(row.source_url); | ||
| if (!norm) return false; | ||
| return prefixes.some(p => typeof p === 'string' && p.trim() && norm.startsWith(normalizeBlocklistPrefix(p))); | ||
| }, | ||
| sweepFragment: (prefixes) => { | ||
| const valid = prefixes.filter(p => typeof p === 'string' && p.trim()); | ||
| if (!valid.length) return null; | ||
| const conds = valid.map((_, i) => `regexp_replace(lower(source_url), '^https?://(www\\.)?', '') LIKE $${i + 1}`).join(' OR '); | ||
| return { sql: `source_url IS NOT NULL AND (${conds})`, params: valid.map(p => normalizeBlocklistPrefix(p) + '%') }; | ||
| } |
There was a problem hiding this comment.
Using startsWith and LIKE 'prefix%' without boundary checks can lead to false positives or bypasses. For example, if example.com is blocklisted, it will match example.com.attacker.com because example.com.attacker.com starts with example.com. To prevent this, we should ensure that the prefix match respects domain/path boundaries (i.e., the prefix must match exactly or be followed by a /).
matches: (row, prefixes) => {
const norm = normalizeUrlForPrefix(row.source_url);
if (!norm) return false;
return prefixes.some(p => {
if (typeof p !== 'string' || !p.trim()) return false;
const prefix = normalizeBlocklistPrefix(p);
return norm === prefix || norm.startsWith(prefix + '/');
});
},
sweepFragment: (prefixes) => {
const valid = prefixes.filter(p => typeof p === 'string' && p.trim());
if (!valid.length) return null;
const conds = valid.map((_, i) => {
const idx = i + 1;
return `regexp_replace(lower(source_url), '^https?://(www\\\\.)?', '') = $${idx} OR regexp_replace(lower(source_url), '^https?://(www\\\\.)?', '') LIKE $${idx} || '/%'`;
}).join(' OR ');
return { sql: `source_url IS NOT NULL AND (${conds})`, params: valid.map(p => normalizeBlocklistPrefix(p)) };
}| export async function evaluatePoiGate(pool, row, votes, deniedPoiIds = new Set()) { | ||
| const total = votes.length; | ||
| const aboutCount = votes.filter(v => v.about_poi).length; | ||
| if (total > 0 && aboutCount * 2 >= total) { | ||
| return { verdict: 'pass', tier: 1, reason: `About assigned POI (${aboutCount}/${total} votes)`, reassigned_from: null, reassigned_to: null, newPoiId: null }; | ||
| } |
There was a problem hiding this comment.
If votes is empty (e.g., relevance voting failed or returned no results), evaluatePoiGate will still try to run Tier 2 reassignment, which makes unnecessary database queries and an extra LLM call. Guarding evaluatePoiGate to immediately return a review verdict when votes is empty avoids these redundant operations.
| export async function evaluatePoiGate(pool, row, votes, deniedPoiIds = new Set()) { | |
| const total = votes.length; | |
| const aboutCount = votes.filter(v => v.about_poi).length; | |
| if (total > 0 && aboutCount * 2 >= total) { | |
| return { verdict: 'pass', tier: 1, reason: `About assigned POI (${aboutCount}/${total} votes)`, reassigned_from: null, reassigned_to: null, newPoiId: null }; | |
| } | |
| export async function evaluatePoiGate(pool, row, votes, deniedPoiIds = new Set()) { | |
| const total = votes.length; | |
| if (total === 0) { | |
| return { verdict: 'review', tier: 3, reason: 'No relevance votes available', reassigned_from: null, reassigned_to: null, newPoiId: null }; | |
| } | |
| const aboutCount = votes.filter(v => v.about_poi).length; | |
| if (aboutCount * 2 >= total) { | |
| return { verdict: 'pass', tier: 1, reason: `About assigned POI (${aboutCount}/${total} votes)`, reassigned_from: null, reassigned_to: null, newPoiId: null }; | |
| } |
…klist SQL trailing-slash) - migration 071: GiST index on pois.boundary_geom for ST_Contains reassignment lookups (PR #447 review) - moderationService: consolidate the two post-gate UPDATE paths into one with COALESCE(publication_date) (PR #447 review) - filterLists: align blocklist sweep SQL normalization with JS (strip trailing slash) (PR #447 review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…view) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Gourmand gate) Pre-existing violation from #445 surfaced by the merge; rename to tokenRow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
searchDate is now weighted 4 (on par with JSON-LD): alone scores 4, beats weak signals, and a conflict with JSON-LD is a 4-4 tie -> score 0 (review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Restructures news/event auto-moderation around three independent gates — Date, Relevance, POI — auto-publishing only when all three pass, and showing each gate's verdict in the admin queue so a pending item reveals exactly which gate needs a human. Spec:
.specify/specs/030-moderation-gates/.moderation_gatesJSONB onpoi_news/poi_events; settingsmoderation_date_floor_year(2010),moderation_sweep_batch_size(50, was hardcoded 20/type).searchDate.owner_id) or smallest containing boundary → review. Deny-listed POIs are filtered out of reassignment candidates so a reassignment can never land on a blocked POI.blocklist_urlsis now a hard-reject deny list (was collection-time skip only) — blocking a domain cleans up already-collected items on the next sweep.ModerationExtras.Verified live against production data: ~80% of a 50-item sample auto-cleared (78% published, 2% rejected), with the remainder held for review on POI/missing-date — and date trust validated against real article bylines.
Test plan
🤖 Generated with Claude Code