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:
co-authored by
Claude Opus 4.8
parent
87a4afec26
commit
fb09ac81c5
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
Vendored
+25
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user