feat(moderation): Indonesian slang normalizer and false-positive prevention

- Add indonesian-badwords dependency for local lexical signal
- Add Indonesian slang lexicon with woy/woi/hadeh as safe casual terms
- Normalize Discord custom emoji <:name:id> to [emoji:name] in prompts
- Wire normalization evidence into both conversationContext and llmModerationClient prompts
- Harden system prompt: woy/woi are casual greetings, not SARA/hate
- Add tests for emoji normalization, slang mapping, badword detection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-29 18:39:10 +07:00
co-authored by Claude Opus 4.8
parent 87a4afec26
commit fb09ac81c5
8 changed files with 327 additions and 40 deletions
+4 -1
View File
@@ -1,3 +1,4 @@
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
import { formatMediaEvidenceForPrompt } from "./messageMetadata.js";
import type { MessageRecord } from "./types.js";
@@ -30,9 +31,11 @@ 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}${mediaSuffix}`;
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${textSuffix}${mediaSuffix}`;
}
/**
+107
View File
@@ -0,0 +1,107 @@
import badwordsModule from "indonesian-badwords";
import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js";
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
const WORD_PATTERN = /[\p{L}\p{N}_]+/gu;
interface BadwordAnalyzeResult {
badwords?: string[];
count?: number;
}
interface BadwordsModule {
analyze?: (text: string) => BadwordAnalyzeResult;
flag?: (text: string) => boolean;
}
const badwords = badwordsModule as BadwordsModule;
export interface ModerationTextEvidence {
raw: string;
normalized: string;
notes: string[];
badwords: string[];
hasBadwords: boolean;
}
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 };
}
export function normalizeIndonesianSlang(text: string): {
text: string;
notes: string[];
} {
const notes: string[] = [];
const normalized = text.replace(WORD_PATTERN, (word) => {
const entry = INDONESIAN_SLANG_LEXICON[word.toLowerCase()];
if (!entry) return word;
notes.push(`${word}=${entry.normalized} (${entry.note})`);
return entry.normalized;
});
return { text: normalized, notes: Array.from(new Set(notes)) };
}
export function detectIndonesianBadwords(text: string): string[] {
try {
const result = badwords.analyze?.(text);
if (Array.isArray(result?.badwords)) {
return Array.from(new Set(result.badwords.map((word) => word.toLowerCase())));
}
} catch {
// Keep moderation pipeline resilient if dependency changes shape.
}
return [];
}
export function buildModerationTextEvidence(text: string): ModerationTextEvidence {
const emojiNormalized = normalizeDiscordCustomEmoji(text);
const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text);
const badwordHits = detectIndonesianBadwords(slangNormalized.text);
const notes = [...slangNormalized.notes];
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(`local lexical check: Indonesian badword detected: ${badwordHits.join(", ")}`);
} else {
notes.push("local lexical check: no Indonesian badword detected");
}
return {
raw: text,
normalized: slangNormalized.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(" ");
}
+8 -2
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { config } from "../config.js";
import { createChildLogger } from "../logger.js";
import { retryWithBackoff } from "../retry.js";
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
import { extractMessageMediaEvidence } from "./messageMetadata.js";
import type {
AnalysisResult,
@@ -663,10 +664,13 @@ Bahasa utama komunitas ini adalah BAHASA INDONESIA. Bahasa Inggris adalah bahasa
## Konteks Server
Ini adalah server Discord komunitas Indonesia. Kamu harus memahami:
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", dll.
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll.
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll.
- Konteks budaya lokal: SARA (Suku, Agama, Ras, Antar-golongan), hoaks, ujaran kebencian berbasis konteks Indonesia.
- Perbedaan antara humor/banter biasa vs konten yang benar-benar melanggar.
- "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas.
- Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi/emoji, bukan pelanggaran teks. Gunakan sebagai konteks ekspresi saja.
- Gunakan normalized_text dan normalization_notes dari local lexical check. Jika notes menyatakan no Indonesian badword detected dan hanya ada slang/emoji aman, jangan flag karena kata slang itu saja.
- Kalimat ambigu dalam bahasa Indonesia harus diberi keputusan final: "clean" bila bukti pelanggaran tidak jelas, "flagged" bila bukti pelanggaran jelas.
- Jangan pernah menulis analisis yang meminta admin/moderator memeriksa ulang, menyebut kurang konteks, atau tidak bisa menentukan. Berikan kesimpulan langsung berdasarkan teks + media + konteks yang tersedia.
- Gambar, sticker, embed, dan preview link adalah evidence utama yang setara dengan teks, bukan sekadar URL teks.
@@ -733,6 +737,8 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
const webTexts = messageWebTextMap.get(msg.id) ?? [];
const mediaAnalyses = messageMediaAnalysisMap.get(msg.id) ?? [];
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
const textEvidence = formatModerationTextEvidenceForPrompt(content);
const textContext = textEvidence ? `\n${textEvidence}` : "";
const mediaAnalysisContext =
mediaAnalyses.length > 0 ? `\n${mediaAnalyses.join("\n")}` : "";
const mediaEvidence = extractMessageMediaEvidence(msg.metadata);
@@ -748,7 +754,7 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
]
.filter(Boolean)
.join(" ");
return `[target] id=${msg.id} user=${msg.username}: ${content}${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}`;
return `[target] id=${msg.id} user=${msg.username}: ${content}${mediaContext ? ` ${mediaContext}` : ""}${textContext}${webContext}${mediaAnalysisContext}`;
})
.join("\n");
@@ -0,0 +1,45 @@
export interface SlangLexiconEntry {
normalized: string;
note: string;
safeByDefault?: boolean;
}
export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
gw: { normalized: "gue", note: "first-person informal pronoun" },
gue: { normalized: "gue", note: "first-person informal pronoun" },
gua: { normalized: "gue", note: "first-person informal pronoun" },
lo: { normalized: "lu", note: "second-person informal pronoun" },
lu: { normalized: "lu", note: "second-person informal pronoun" },
loe: { normalized: "lu", note: "second-person informal pronoun" },
yg: { normalized: "yang", note: "common abbreviation" },
emg: { normalized: "memang", note: "common abbreviation" },
kyk: { normalized: "kayak", note: "common abbreviation" },
tdk: { normalized: "tidak", note: "common abbreviation" },
krn: { normalized: "karena", note: "common abbreviation" },
jgn: { normalized: "jangan", note: "common abbreviation" },
woy: {
normalized: "woy",
note: "casual Indonesian interjection/greeting; not SARA/hate/harassment by default",
safeByDefault: true,
},
woi: {
normalized: "woi",
note: "casual Indonesian interjection/greeting; not SARA/hate/harassment by default",
safeByDefault: true,
},
oi: {
normalized: "oi",
note: "casual call/interjection; not offensive by default",
safeByDefault: true,
},
hadeh: {
normalized: "hadeh",
note: "facepalm/tired expression; not offensive by default",
safeByDefault: true,
},
hadeuh: {
normalized: "hadeh",
note: "facepalm/tired expression; not offensive by default",
safeByDefault: true,
},
};
+25
View File
@@ -0,0 +1,25 @@
declare module "indonesian-badwords" {
export interface BadwordAnalyzeResult {
text?: string;
words?: number;
censored?: string;
badwords?: string[];
count?: number;
locations?: Array<{ word: string; index: number }>;
}
export function analyze(text: string): BadwordAnalyzeResult;
export function flag(text: string): boolean;
export function filter(text: string): string;
export function censor(text: string): string;
const value: {
analyze: typeof analyze;
flag: typeof flag;
filter: typeof filter;
censor: typeof censor;
dict?: unknown;
badwords?: unknown;
};
export default value;
}