fix(ai-moderation): remove indonesianTextNormalizer to stop hallucinated slang flags
The rule-based badword detector was injecting [normalized_text] and
[normalization_notes] tags into the LLM prompt that caused false
positive hallucinations - the LLM started associating innocent words
('sapik', 'furina') with furry/sexual_deviation due to misleading
context injected by the normalizer.
Removed:
- indonesianTextNormalizer.ts (full file deletion)
- formatModerationTextEvidenceForPrompt import/usage in llmModerationClient
- formatModerationTextEvidenceForPrompt import/usage in conversationContext
- stale re-exports in index.ts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cfe7230a55
commit
e6e44b30c7
@@ -1,7 +1,6 @@
|
||||
import { encoding_for_model as encodingForModel } from "tiktoken";
|
||||
import { formatMediaEvidenceForPrompt } from "../message-capture/messageMetadata.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||
|
||||
export interface ConversationContextInput {
|
||||
contextBefore: MessageRecord[];
|
||||
@@ -42,11 +41,9 @@ export function formatMessageForPrompt(
|
||||
): string {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
const timestamp = formatTimestamp(msg.created_at);
|
||||
const textEvidence = formatModerationTextEvidenceForPrompt(content);
|
||||
const textSuffix = textEvidence ? ` ${textEvidence}` : "";
|
||||
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
||||
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${textSuffix}${mediaSuffix}`;
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export { startPendingAIAnalysisWorker } from "./aiAnalyzer.js";
|
||||
export {
|
||||
buildModerationTextEvidence,
|
||||
detectIndonesianBadwords,
|
||||
normalizeDiscordCustomEmoji,
|
||||
} from "./indonesianTextNormalizer.js";
|
||||
export { runModerationAnalysis } from "./llmModerationClient.js";
|
||||
export { buildSystemPrompt } from "./moderationPrompt.js";
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
// No imports needed — pure rule-based, no external dependencies.
|
||||
|
||||
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
|
||||
/**
|
||||
* In-memory cache TTL (10 min) — avoids re-scanning identical text.
|
||||
*/
|
||||
const BADWORD_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
interface BadwordCacheEntry {
|
||||
value: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const badwordCache = new Map<string, BadwordCacheEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe pattern pre-filter — short-circuits definitively safe messages.
|
||||
// Conservative: only returns true for patterns that CANNOT be violations.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAFE_PATTERNS: Array<{
|
||||
test: (text: string) => boolean;
|
||||
reason: string;
|
||||
}> = [
|
||||
{
|
||||
test: (t) =>
|
||||
/^(wkwk+|w+kw+k+|wkwkw+|haha+|hehe+|hihi+|huhu+|xixi+|wakak+|awkwa+)$/i.test(
|
||||
t,
|
||||
),
|
||||
reason: "laughter pattern",
|
||||
},
|
||||
{
|
||||
test: (t) =>
|
||||
/^(ok|oke|okay|sip|siap|aman|mantap|gas|gass|gaskeun|santuy|gaskan|lah|wih|wah|eh|nah|loh|hmm|hm|heh)$/i.test(
|
||||
t,
|
||||
),
|
||||
reason: "single-word affirmative",
|
||||
},
|
||||
{
|
||||
test: (t) =>
|
||||
/^(hai|halo|hello|hi|oi|woy|woi|pagi|siang|sore|malam|mlm|p|w|L|F|gws|thx|thks|makasih|ty|thanks|yw|sama-sama|ok sip|ok bang|siap bang)$/i.test(
|
||||
t,
|
||||
),
|
||||
reason: "greeting/common expression",
|
||||
},
|
||||
{
|
||||
test: (t) => t.length <= 2,
|
||||
reason: "very short message (1-2 chars)",
|
||||
},
|
||||
{
|
||||
test: (t) => /^[\d\s.,!?;:'"()\-_]+$/.test(t),
|
||||
reason: "numeric/punctuation only",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rule-based Indonesian badword detection (NO LLM calls)
|
||||
//
|
||||
// Uses word-boundary regex matching to detect known Indonesian badwords.
|
||||
// Context-aware: matches only whole words to avoid false positives like
|
||||
// "asu" in "kasus", "kontol" in "rekontolasi".
|
||||
//
|
||||
// Each category maps to a flag the moderation LLM can use as context.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BadwordEntry {
|
||||
words: string[];
|
||||
flag: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const BADWORD_CATEGORIES: BadwordEntry[] = [
|
||||
{
|
||||
description: "vulgar genitalia / sexual terms",
|
||||
flag: "vulgar_language",
|
||||
words: [
|
||||
"kontol",
|
||||
"memek",
|
||||
"pepek",
|
||||
"tempik",
|
||||
"peler",
|
||||
"pelir",
|
||||
"pukimak",
|
||||
"pukima",
|
||||
"jancok",
|
||||
"jancuk",
|
||||
"cok",
|
||||
"cuk",
|
||||
"pantek",
|
||||
"palek",
|
||||
"ngentot",
|
||||
"ngewe",
|
||||
"entot",
|
||||
"ewe",
|
||||
"coli",
|
||||
"sange",
|
||||
"sangean",
|
||||
"ngocok",
|
||||
"bangkot",
|
||||
"nenen",
|
||||
"tete",
|
||||
"tetek",
|
||||
"dodot",
|
||||
"kentu",
|
||||
"perek",
|
||||
"bispak",
|
||||
"bangsat",
|
||||
"babi",
|
||||
"asu",
|
||||
"anjing",
|
||||
"anjir",
|
||||
"anjirt",
|
||||
"njing",
|
||||
"njir",
|
||||
"anjay",
|
||||
"kampret",
|
||||
"kampang",
|
||||
"brengsek",
|
||||
"brengus",
|
||||
"bejad",
|
||||
"bajingan",
|
||||
"goblok",
|
||||
"tolol",
|
||||
"bego",
|
||||
"dungu",
|
||||
"idiot",
|
||||
"beban",
|
||||
"keparat",
|
||||
"setan",
|
||||
"iblis",
|
||||
"sialan",
|
||||
"sial",
|
||||
"kacang",
|
||||
"edan",
|
||||
"gila",
|
||||
"titten",
|
||||
"bitch",
|
||||
"whore",
|
||||
"slut"
|
||||
],
|
||||
},
|
||||
{
|
||||
description: "harassment / targeted insults",
|
||||
flag: "harassment",
|
||||
words: [
|
||||
"mampus",
|
||||
"mati",
|
||||
"bunuh",
|
||||
"bacot",
|
||||
"cupu",
|
||||
"geblek",
|
||||
"kere",
|
||||
"ngawur",
|
||||
"sembarangan",
|
||||
"nyampah",
|
||||
"nyampah",
|
||||
"sarap",
|
||||
"ke laut aja",
|
||||
"gila lu",
|
||||
"sinting",
|
||||
"editan",
|
||||
"mending mati",
|
||||
"monyet",
|
||||
"kuda",
|
||||
"unta",
|
||||
"bangke",
|
||||
"bangsat",
|
||||
"kys",
|
||||
"kill yourself"
|
||||
],
|
||||
},
|
||||
{
|
||||
description: "SARA / racial slurs (non-exhaustive)",
|
||||
flag: "sara",
|
||||
words: [
|
||||
"cina",
|
||||
"tionghoa",
|
||||
"pribumi",
|
||||
"non-pribumi",
|
||||
"kaffir",
|
||||
"kafir",
|
||||
"murtad",
|
||||
"sesat",
|
||||
"liberal",
|
||||
"komunis",
|
||||
"komunisme",
|
||||
"pki",
|
||||
],
|
||||
},
|
||||
{
|
||||
description: "gambling / judi",
|
||||
flag: "gambling",
|
||||
words: [
|
||||
"judi",
|
||||
"slot",
|
||||
"togel",
|
||||
"toto gelap",
|
||||
"casino",
|
||||
"roulette",
|
||||
"poker",
|
||||
"domino",
|
||||
"gaple",
|
||||
"sabung ayam",
|
||||
"bola jalan",
|
||||
"maxwin",
|
||||
"gacor",
|
||||
"scatter",
|
||||
"bonanza",
|
||||
"olympus",
|
||||
],
|
||||
},
|
||||
{
|
||||
description: "hate speech / extreme discrimination",
|
||||
flag: "hate_speech",
|
||||
words: [
|
||||
"bencina",
|
||||
"bencin",
|
||||
"bangsat",
|
||||
"dajjal",
|
||||
"laknat",
|
||||
"keparat",
|
||||
"dasar cina",
|
||||
"dasar tionghoa",
|
||||
"dasar pribumi",
|
||||
"nigger",
|
||||
"nigga"
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a single combined regex per category that matches whole words only.
|
||||
* Uses word boundaries (\b) so "asu" matches "asu" but not "kasus".
|
||||
* For multi-word entries, builds an alternation of the full phrases.
|
||||
*/
|
||||
const BADWORD_REGEX_CACHE = new Map<string, RegExp>();
|
||||
|
||||
function buildBadwordRegex(words: string[]): RegExp {
|
||||
// Sort by length descending so longer phrases match before their substrings
|
||||
const sorted = [...words].sort((a, b) => b.length - a.length);
|
||||
// Escape regex special chars in each word
|
||||
const escaped = sorted.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const pattern = escaped
|
||||
.map((w) => {
|
||||
// Multi-word phrases (containing space) — match as-is
|
||||
if (w.includes("\\ ")) return w;
|
||||
// Single word — word boundaries
|
||||
return `\\b${w}\\b`;
|
||||
})
|
||||
.join("|");
|
||||
return new RegExp(pattern, "i");
|
||||
}
|
||||
|
||||
function matchBadwords(text: string): string[] {
|
||||
const hits: Set<string> = new Set();
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const category of BADWORD_CATEGORIES) {
|
||||
let regex = BADWORD_REGEX_CACHE.get(category.flag);
|
||||
if (!regex) {
|
||||
regex = buildBadwordRegex(category.words);
|
||||
BADWORD_REGEX_CACHE.set(category.flag, regex);
|
||||
}
|
||||
if (regex.test(lowerText)) {
|
||||
hits.add(category.flag);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(hits);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Checks whether a text message is definitively safe and does not need
|
||||
* badword detection at all.
|
||||
*/
|
||||
export function isDefinitivelySafe(text: string): boolean {
|
||||
const { text: normalized } = normalizeDiscordCustomEmoji(text);
|
||||
const trimmed = normalized.trim();
|
||||
if (trimmed.length === 0) return true;
|
||||
return SAFE_PATTERNS.some((p) => p.test(trimmed));
|
||||
}
|
||||
|
||||
export function normalizeDiscordCustomEmoji(text: string): {
|
||||
text: string;
|
||||
emojiNames: string[];
|
||||
} {
|
||||
const emojiNames: string[] = [];
|
||||
const normalized = text.replace(
|
||||
CUSTOM_EMOJI_PATTERN,
|
||||
(_match, name: string) => {
|
||||
emojiNames.push(name);
|
||||
return `[emoji:${name}]`;
|
||||
},
|
||||
);
|
||||
return { text: normalized, emojiNames };
|
||||
}
|
||||
|
||||
function normalizeBadwordCacheKey(text: string): string {
|
||||
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function getCachedBadwords(key: string): string[] | null {
|
||||
const entry = badwordCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
badwordCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return [...entry.value];
|
||||
}
|
||||
|
||||
function setCachedBadwords(key: string, value: string[]): void {
|
||||
badwordCache.set(key, {
|
||||
value: [...new Set(value)],
|
||||
expiresAt: Date.now() + BADWORD_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
if (badwordCache.size > 500) {
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of badwordCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
badwordCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
if (badwordCache.size > 500) {
|
||||
const oldestKeys = Array.from(badwordCache.entries())
|
||||
.sort((a, b) => a[1].expiresAt - b[1].expiresAt)
|
||||
.slice(0, badwordCache.size - 500)
|
||||
.map(([cacheKey]) => cacheKey);
|
||||
for (const cacheKey of oldestKeys) {
|
||||
badwordCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PURE RULE-BASED badword detection (synchronous, no LLM calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect badwords in text using pure rule-based matching.
|
||||
*
|
||||
* Previously used a 3-tier pipeline (in-memory → DB → LLM API call) that
|
||||
* caused N+1 LLM calls per batch, multiplying costs by ~10x.
|
||||
*
|
||||
* Now uses word-boundary regex matching against known Indonesian badword
|
||||
* categories. Fully synchronous — no DB, no API, no async overhead.
|
||||
*
|
||||
* Cache retained as a simple in-memory LRU for repeated identical texts.
|
||||
*/
|
||||
export function detectIndonesianBadwords(text: string): string[] {
|
||||
const cacheKey = normalizeBadwordCacheKey(text);
|
||||
|
||||
// ── In-memory cache (fastest) ──
|
||||
const cached = getCachedBadwords(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// ── Safe pre-filter ──
|
||||
if (isDefinitivelySafe(text)) {
|
||||
setCachedBadwords(cacheKey, []);
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Rule-based matching ──
|
||||
const hits = matchBadwords(text);
|
||||
setCachedBadwords(cacheKey, hits);
|
||||
return hits;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synchronous evidence builders (no async needed anymore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ModerationTextEvidence {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
notes: string[];
|
||||
badwords: string[];
|
||||
hasBadwords: boolean;
|
||||
}
|
||||
|
||||
export function buildModerationTextEvidence(
|
||||
text: string,
|
||||
): ModerationTextEvidence {
|
||||
const emojiNormalized = normalizeDiscordCustomEmoji(text);
|
||||
const badwordHits = detectIndonesianBadwords(emojiNormalized.text);
|
||||
const notes: string[] = [];
|
||||
|
||||
for (const emojiName of emojiNormalized.emojiNames) {
|
||||
notes.push(
|
||||
`emoji:${emojiName}=Discord custom emoji/expression; not text offense by default`,
|
||||
);
|
||||
}
|
||||
|
||||
if (badwordHits.length > 0) {
|
||||
notes.push(`Known badword detected: ${badwordHits.join(", ")}`);
|
||||
} else {
|
||||
notes.push("no known badword detected");
|
||||
}
|
||||
|
||||
return {
|
||||
raw: text,
|
||||
normalized: emojiNormalized.text,
|
||||
notes: Array.from(new Set(notes)),
|
||||
badwords: badwordHits,
|
||||
hasBadwords: badwordHits.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatModerationTextEvidenceForPrompt(text: string): string {
|
||||
const evidence = buildModerationTextEvidence(text);
|
||||
if (evidence.normalized === evidence.raw && evidence.notes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
`[normalized_text: ${evidence.normalized}]`,
|
||||
evidence.notes.length > 0
|
||||
? `[normalization_notes: ${evidence.notes.join("; ")}]`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
AttachmentRecord,
|
||||
MessageRecord,
|
||||
} from "../message-capture/types.js";
|
||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||
|
||||
import { llmChat, llmVision } from "./llmClient.js";
|
||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||
import {
|
||||
@@ -999,16 +999,6 @@ async function runTextOnlyBatch(
|
||||
const maxBatchSize = config.AI_LLM_TEXT_BATCH_SIZE ?? 20;
|
||||
const timeoutMs = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||
|
||||
// Pre-compute text evidence (normalization + badword detection)
|
||||
const textEvidenceMap = new Map<string, string>();
|
||||
await Promise.all(
|
||||
targets.map(async (msg) => {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
const evidence = formatModerationTextEvidenceForPrompt(content);
|
||||
textEvidenceMap.set(msg.id, evidence);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Fetch web content from URLs in text-only messages ──
|
||||
// Prevents LLM from guessing based on domain name alone (e.g., false "scam" flags).
|
||||
// The fetched text content is injected into the message XML so the LLM can
|
||||
@@ -1156,8 +1146,6 @@ async function runTextOnlyBatch(
|
||||
const messagesBlock = batch
|
||||
.map((msg) => {
|
||||
const content = getAnalysisContent(msg);
|
||||
const textEvidence = textEvidenceMap.get(msg.id) ?? "";
|
||||
const textContext = textEvidence ? `\n${textEvidence}` : "";
|
||||
|
||||
// Inject fetched web content for URLs found in this message
|
||||
const msgUrls = extractUrlsFromText(content);
|
||||
@@ -1173,7 +1161,7 @@ async function runTextOnlyBatch(
|
||||
const userCtx = userContexts.get(msg.user_id) ?? "";
|
||||
|
||||
// XML delimiters wrap each message for prompt safety (R1)
|
||||
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}\n <content>${content}</content>${textContext}${webContext}\n</message>`;
|
||||
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}\n <content>${content}</content>${webContext}\n</message>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -1652,12 +1640,9 @@ async function _runSingleMediaAnalysis(
|
||||
);
|
||||
|
||||
// ── 5. Build single-message prompt with XML delimiters (R1) ──
|
||||
const textEvidence = formatModerationTextEvidenceForPrompt(content);
|
||||
|
||||
const webTexts = webTextMap.get(targetId) ?? [];
|
||||
const mediaAnalyses = mediaAnalysisMap.get(targetId) ?? [];
|
||||
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
||||
const textContext = textEvidence ? `\n${textEvidence}` : "";
|
||||
const mediaAnalysisContext =
|
||||
mediaAnalyses.length > 0 ? `\n${mediaAnalyses.join("\n")}` : "";
|
||||
|
||||
@@ -1693,7 +1678,7 @@ async function _runSingleMediaAnalysis(
|
||||
const userCtx = `<user_reputation trust_score="${rep.trust_score}" clean_streak="${rep.clean_message_streak}" total_infractions="${rep.total_infractions}" />${historyStr}`;
|
||||
|
||||
// XML delimiters wrap the message content (R1)
|
||||
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${textContext}${webContext}${mediaAnalysisContext}\n</message>`;
|
||||
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
|
||||
|
||||
// Modular system prompt with XML delimiters (R1, R7, R8)
|
||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||
|
||||
Reference in New Issue
Block a user