2026-05-30 14:48:50 +07:00
|
|
|
import axios from "axios";
|
|
|
|
|
import { config } from "../config.js";
|
2026-05-29 18:39:10 +07:00
|
|
|
import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js";
|
2026-05-30 14:48:50 +07:00
|
|
|
import { createChildLogger } from "../logger.js";
|
|
|
|
|
|
|
|
|
|
const log = createChildLogger("indonesianTextNormalizer");
|
2026-05-29 18:39:10 +07:00
|
|
|
|
|
|
|
|
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
|
|
|
|
|
const WORD_PATTERN = /[\p{L}\p{N}_]+/gu;
|
|
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
/** NVIDIA content safety categories that map to offensive/badword content. */
|
|
|
|
|
const NVIDIA_BAD_CATEGORIES = new Set([
|
|
|
|
|
"hate",
|
|
|
|
|
"harassment",
|
|
|
|
|
"sexual",
|
|
|
|
|
"violence",
|
|
|
|
|
"self-harm",
|
|
|
|
|
"illicit",
|
|
|
|
|
"profanity",
|
|
|
|
|
"vulgar",
|
|
|
|
|
"insult",
|
|
|
|
|
]);
|
2026-05-29 18:39:10 +07:00
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
/**
|
|
|
|
|
* Map NVIDIA Nemotron category labels to Indonesian badword-style labels.
|
|
|
|
|
*/
|
|
|
|
|
const CATEGORY_TO_BADWORD_LABEL: Record<string, string> = {
|
|
|
|
|
hate: "hate_speech",
|
|
|
|
|
harassment: "harassment",
|
|
|
|
|
sexual: "sexual_content",
|
|
|
|
|
violence: "violence",
|
|
|
|
|
"self-harm": "self_harm",
|
|
|
|
|
illicit: "illegal_content",
|
|
|
|
|
profanity: "vulgar_language",
|
|
|
|
|
vulgar: "vulgar_language",
|
|
|
|
|
insult: "harassment",
|
|
|
|
|
};
|
2026-05-29 18:39:10 +07:00
|
|
|
|
|
|
|
|
export interface ModerationTextEvidence {
|
|
|
|
|
raw: string;
|
|
|
|
|
normalized: string;
|
|
|
|
|
notes: string[];
|
|
|
|
|
badwords: string[];
|
|
|
|
|
hasBadwords: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Sync helpers (unchanged)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-29 18:39:10 +07:00
|
|
|
export function normalizeDiscordCustomEmoji(text: string): {
|
|
|
|
|
text: string;
|
|
|
|
|
emojiNames: string[];
|
|
|
|
|
} {
|
|
|
|
|
const emojiNames: string[] = [];
|
2026-05-30 15:55:01 +07:00
|
|
|
const normalized = text.replace(
|
|
|
|
|
CUSTOM_EMOJI_PATTERN,
|
|
|
|
|
(_match, name: string) => {
|
|
|
|
|
emojiNames.push(name);
|
|
|
|
|
return `[emoji:${name}]`;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-29 18:39:10 +07:00
|
|
|
|
|
|
|
|
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)) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Local fallback badword list (used when NVIDIA API is unavailable)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-30 00:14:28 +07:00
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
const LOCAL_BADWORDS = [
|
2026-05-30 15:55:01 +07:00
|
|
|
"anjing",
|
|
|
|
|
"bangsat",
|
|
|
|
|
"brengsek",
|
|
|
|
|
"bajingan",
|
|
|
|
|
"kontol",
|
|
|
|
|
"memek",
|
|
|
|
|
"tai",
|
|
|
|
|
"goblok",
|
|
|
|
|
"tolol",
|
|
|
|
|
"bego",
|
|
|
|
|
"sialan",
|
|
|
|
|
"jancuk",
|
|
|
|
|
"kampret",
|
|
|
|
|
"pepek",
|
|
|
|
|
"jembut",
|
|
|
|
|
"ngentot",
|
|
|
|
|
"ngewe",
|
|
|
|
|
"coli",
|
|
|
|
|
"celaka",
|
|
|
|
|
"laknat",
|
|
|
|
|
"pantek",
|
|
|
|
|
"entod",
|
|
|
|
|
"ndasmu",
|
|
|
|
|
"ndas",
|
|
|
|
|
"piyo",
|
|
|
|
|
"asu",
|
2026-05-30 14:48:50 +07:00
|
|
|
];
|
2026-05-30 00:14:28 +07:00
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
const FALSE_POSITIVE_WHITELISTS: Record<string, string[]> = {
|
|
|
|
|
asu: [
|
2026-05-30 15:55:01 +07:00
|
|
|
"asus",
|
|
|
|
|
"masuk",
|
|
|
|
|
"termasuk",
|
|
|
|
|
"dimasukkan",
|
|
|
|
|
"memasukkan",
|
|
|
|
|
"kasur",
|
|
|
|
|
"asumsi",
|
|
|
|
|
"asuransi",
|
|
|
|
|
"asupan",
|
|
|
|
|
"pasukan",
|
|
|
|
|
"pasundan",
|
2026-05-30 14:48:50 +07:00
|
|
|
],
|
|
|
|
|
goblok: ["goblok"],
|
|
|
|
|
kontol: ["kontol"],
|
|
|
|
|
memek: ["memek"],
|
|
|
|
|
tolol: ["tolol"],
|
|
|
|
|
};
|
2026-05-30 00:14:28 +07:00
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
function detectLocalBadwords(text: string): string[] {
|
|
|
|
|
const lowerText = text.toLowerCase();
|
|
|
|
|
const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || [];
|
2026-05-30 00:14:28 +07:00
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
const isRealHit = (hit: string, whitelist: string[]): boolean => {
|
|
|
|
|
for (const w of words) {
|
|
|
|
|
if (w.includes(hit)) {
|
|
|
|
|
if (w === hit) return true;
|
|
|
|
|
if (!whitelist.includes(w)) return true;
|
2026-05-30 00:14:28 +07:00
|
|
|
}
|
2026-05-29 18:39:10 +07:00
|
|
|
}
|
2026-05-30 14:48:50 +07:00
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hits: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const badword of LOCAL_BADWORDS) {
|
|
|
|
|
const whitelist = FALSE_POSITIVE_WHITELISTS[badword] ?? [badword];
|
|
|
|
|
if (isRealHit(badword, whitelist)) {
|
|
|
|
|
hits.push(badword);
|
|
|
|
|
}
|
2026-05-29 18:39:10 +07:00
|
|
|
}
|
2026-05-30 14:48:50 +07:00
|
|
|
|
|
|
|
|
return Array.from(new Set(hits));
|
2026-05-29 18:39:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 14:48:50 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// NVIDIA Nemotron-3 Content Safety API
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Call NVIDIA Nemotron-3 Content Safety API to detect harmful content.
|
|
|
|
|
* Returns categories/flags from the API response.
|
|
|
|
|
*/
|
|
|
|
|
async function callNemotronContentSafety(text: string): Promise<string[]> {
|
|
|
|
|
const apiKey = config.NVIDIA_NEMOTRON_API_KEY;
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await axios.post(
|
|
|
|
|
config.NVIDIA_NEMOTRON_BASE_URL,
|
|
|
|
|
{
|
|
|
|
|
model: config.NVIDIA_NEMOTRON_MODEL,
|
|
|
|
|
messages: [{ role: "user", content: text }],
|
|
|
|
|
max_tokens: 897,
|
|
|
|
|
temperature: 0.2,
|
|
|
|
|
top_p: 0.7,
|
|
|
|
|
stream: false,
|
|
|
|
|
chat_template_kwargs: { request_categories: "/categories" },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${apiKey}`,
|
|
|
|
|
Accept: "application/json",
|
|
|
|
|
},
|
|
|
|
|
timeout: 15_000,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = response.data;
|
|
|
|
|
const categories: string[] = [];
|
|
|
|
|
|
|
|
|
|
// Parse the LLM response for category flags
|
|
|
|
|
const content = data?.choices?.[0]?.message?.content ?? "";
|
|
|
|
|
if (content) {
|
|
|
|
|
const lowerContent = content.toLowerCase();
|
|
|
|
|
for (const category of NVIDIA_BAD_CATEGORIES) {
|
|
|
|
|
// Check if the category appears as a key in the response
|
|
|
|
|
// The Nemotron content safety model returns structured data with category scores
|
|
|
|
|
if (lowerContent.includes(category)) {
|
|
|
|
|
categories.push(CATEGORY_TO_BADWORD_LABEL[category] ?? category);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also check for structured response fields
|
|
|
|
|
const choice = data?.choices?.[0];
|
|
|
|
|
if (choice?.message?.content) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(choice.message.content);
|
|
|
|
|
if (parsed.categories && Array.isArray(parsed.categories)) {
|
|
|
|
|
for (const cat of parsed.categories) {
|
|
|
|
|
if (NVIDIA_BAD_CATEGORIES.has(cat.name ?? cat)) {
|
|
|
|
|
categories.push(CATEGORY_TO_BADWORD_LABEL[cat.name ?? cat] ?? cat);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Not JSON — already handled via text search above
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(new Set(categories));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detect badwords in text using NVIDIA Nemotron-3 Content Safety API.
|
|
|
|
|
* Falls back to local lexical list if API key is missing or call fails.
|
|
|
|
|
*/
|
2026-05-30 15:55:01 +07:00
|
|
|
export async function detectIndonesianBadwords(
|
|
|
|
|
text: string,
|
|
|
|
|
): Promise<string[]> {
|
2026-05-30 14:48:50 +07:00
|
|
|
// Always run local detection first (fast, no network dependency)
|
|
|
|
|
const localHits = detectLocalBadwords(text);
|
|
|
|
|
|
|
|
|
|
// Try NVIDIA API if key is configured
|
|
|
|
|
const apiKey = config.NVIDIA_NEMOTRON_API_KEY;
|
|
|
|
|
if (apiKey) {
|
|
|
|
|
try {
|
|
|
|
|
const apiCategories = await callNemotronContentSafety(text);
|
|
|
|
|
const allHits = Array.from(new Set([...localHits, ...apiCategories]));
|
|
|
|
|
return allHits;
|
|
|
|
|
} catch (error) {
|
2026-05-30 15:55:01 +07:00
|
|
|
log.warn(
|
|
|
|
|
{ error },
|
|
|
|
|
"NVIDIA Nemotron API call failed, falling back to local detection",
|
|
|
|
|
);
|
2026-05-30 14:48:50 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return localHits;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Async evidence builders
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-30 15:55:01 +07:00
|
|
|
export async function buildModerationTextEvidence(
|
|
|
|
|
text: string,
|
|
|
|
|
): Promise<ModerationTextEvidence> {
|
2026-05-29 18:39:10 +07:00
|
|
|
const emojiNormalized = normalizeDiscordCustomEmoji(text);
|
|
|
|
|
const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text);
|
2026-05-30 14:48:50 +07:00
|
|
|
const badwordHits = await detectIndonesianBadwords(slangNormalized.text);
|
2026-05-29 18:39:10 +07:00
|
|
|
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) {
|
2026-05-30 14:48:50 +07:00
|
|
|
notes.push(`Indonesian badword detected: ${badwordHits.join(", ")}`);
|
2026-05-29 18:39:10 +07:00
|
|
|
} else {
|
2026-05-30 14:48:50 +07:00
|
|
|
notes.push("no Indonesian badword detected");
|
2026-05-29 18:39:10 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
raw: text,
|
|
|
|
|
normalized: slangNormalized.text,
|
|
|
|
|
notes: Array.from(new Set(notes)),
|
|
|
|
|
badwords: badwordHits,
|
|
|
|
|
hasBadwords: badwordHits.length > 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:55:01 +07:00
|
|
|
export async function formatModerationTextEvidenceForPrompt(
|
|
|
|
|
text: string,
|
|
|
|
|
): Promise<string> {
|
2026-05-30 14:48:50 +07:00
|
|
|
const evidence = await buildModerationTextEvidence(text);
|
2026-05-29 18:39:10 +07:00
|
|
|
if (evidence.normalized === evidence.raw && evidence.notes.length === 0) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
`[normalized_text: ${evidence.normalized}]`,
|
2026-05-30 15:55:01 +07:00
|
|
|
evidence.notes.length > 0
|
|
|
|
|
? `[normalization_notes: ${evidence.notes.join("; ")}]`
|
|
|
|
|
: null,
|
2026-05-29 18:39:10 +07:00
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" ");
|
|
|
|
|
}
|