fix(moderation): interleave images with owning messages, Indonesian-first prompt

- Replace flat imageParts prologue with per-message image map (messageImageMap)
  keyed by message_id. Images are now inserted immediately after their owning
  message's text part in the multimodal content array, giving the vision model
  proper text+image co-context instead of a disconnected image dump before the
  entire prompt.

- Rewrite moderationPrompt as Indonesian-first bilingual system prompt:
  * Primary language: Bahasa Indonesia; English secondary
  * Explicit Discord community context with Indonesian slang awareness
    (anjay, wkwk, santuy, gw/lo abbreviations, etc.)
  * SARA, hoaks, ujaran kebencian cultural context
  * Charitable intent for ambiguous Indonesian phrasing
  * Expanded flag taxonomy: sara, hoaks, nsfw_image, gore_image, doxxing, scam
  * analysis field instructed in Bahasa Indonesia (maks 2 kalimat)
  * Retry/correction messages also in Bahasa Indonesia

- Image instruction block conditionally injected into prompt only when
  hasImages=true, explicitly telling model to treat image + preceding text
  as one semantic unit and to OCR meme/screenshot text as message content.
This commit is contained in:
MythEclipse
2026-05-22 01:04:00 +07:00
parent d0e906763e
commit 4ec9b50f33
+193 -124
View File
@@ -511,149 +511,218 @@ export async function runModerationAnalysis(
const targetIds = targets.map((t) => t.id);
const messagesText = targets
.map((msg) => `[${msg.id}] ${msg.username}: ${msg.content}`)
.join("\n");
// Build a lookup: message_id → list of resolved base64 image parts
type RawImagePart = { type: "image_url"; image_url: { url: string } };
type MessageImageMap = Map<string, RawImagePart[]>;
const moderationPrompt = `You are a content moderation assistant. Analyze messages for policy violations.
// Resolve and download image attachments, grouped by message_id.
// Only images whose message_id appears in the full attachment list are kept;
// target messages get priority in the 8-image global cap.
const getAttachmentImageUrl = (att: AttachmentRecord): string | null =>
att.uploaded_url ?? null;
Context:
${contextText}
Messages to analyze:
${messagesText}
For each message, respond with a JSON object containing a "results" array.
CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers.
Each result must have:
- message_id: the message ID (STRING, exactly as provided)
- status: "clean", "warn", or "flagged"
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
- score: confidence score from 0 to 1
- analysis: brief explanation
Do not include reasoning, analysis steps, markdown, prose, XML tags, or comments.
Return ONLY valid JSON, no other text.`;
// Check for image attachments to support multimodal analysis
const targetIdSet = new Set(targets.map((t) => t.id));
const getAttachmentImageUrl = (att: AttachmentRecord): string | null => {
if (att.uploaded_url) return att.uploaded_url;
return null;
};
const imageAttachments = (attachments || [])
.filter(
(att) => getAttachmentImageUrl(att) && att.type.startsWith("image/"),
)
const candidateAttachments = (attachments ?? [])
.filter((att) => getAttachmentImageUrl(att) && att.type.startsWith("image/"))
.sort((a, b) => {
// Target-message attachments always come first so they consume the cap first
const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0;
const bIsTarget = targetIdSet.has(b.message_id) ? 1 : 0;
if (aIsTarget !== bIsTarget) {
return bIsTarget - aIsTarget; // Target messages first
}
return b.created_at - a.created_at; // Most recent first
if (aIsTarget !== bIsTarget) return bIsTarget - aIsTarget;
// Within the same priority tier, newest first
return b.created_at - a.created_at;
})
.slice(0, 8); // Cap at 8 to prevent LLM API limits (e.g. Nemotron/Omni models 8-image limit)
.slice(0, 8); // Hard cap — some vision APIs (Nemotron, Omni) reject >8 images
type MessageContent =
| string
| Array<{ type: string; text?: string; image_url?: { url: string } }>;
const messageImageMap: MessageImageMap = new Map();
let imageParts: Array<{
type: string;
text?: string;
image_url?: { url: string };
}> = [];
if (imageAttachments.length > 0) {
imageParts = (
await Promise.all(
imageAttachments.map(async (att) => {
try {
const urlToUse = getAttachmentImageUrl(att);
if (!urlToUse) return [];
log.info(
{ attachmentId: att.id, url: urlToUse },
"Downloading attachment for base64 encoding",
);
const res = await fetch(urlToUse);
if (!res.ok) {
log.warn(
{ attachmentId: att.id, status: res.status, url: urlToUse },
"Failed to fetch attachment image — non-2xx status",
);
return [];
}
await Promise.all(
candidateAttachments.map(async (att) => {
const urlToUse = getAttachmentImageUrl(att);
if (!urlToUse) return;
const buffer = await res.arrayBuffer();
const imageBytes = Buffer.from(buffer);
try {
log.info(
{ attachmentId: att.id, messageId: att.message_id, url: urlToUse },
"Downloading attachment for base64 encoding",
);
const res = await fetch(urlToUse);
if (!res.ok) {
log.warn(
{ attachmentId: att.id, status: res.status, url: urlToUse },
"Failed to fetch attachment image — non-2xx status",
);
return;
}
// Guard against HTML error pages, redirects, or octet streams
// that the CDN might serve under a stale URL.
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime) {
log.warn(
{
attachmentId: att.id,
url: urlToUse,
dbType: att.type,
bytesLength: imageBytes.length,
// First 16 bytes as hex to aid diagnosis
headerHex: imageBytes.subarray(0, 16).toString("hex"),
},
"Skipping attachment: downloaded bytes are not a recognised image format",
);
return [];
}
const imageBytes = Buffer.from(await res.arrayBuffer());
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime) {
log.warn(
{
attachmentId: att.id,
url: urlToUse,
dbType: att.type,
bytesLength: imageBytes.length,
headerHex: imageBytes.subarray(0, 16).toString("hex"),
},
"Skipping attachment: downloaded bytes are not a recognised image format",
);
return;
}
const base64Str = imageBytes.toString("base64");
const dataUrl = `data:${sniffedMime};base64,${base64Str}`;
const dataUrl = `data:${sniffedMime};base64,${imageBytes.toString("base64")}`;
const part: RawImagePart = { type: "image_url", image_url: { url: dataUrl } };
return [
{
type: "image_url",
image_url: {
url: dataUrl,
},
},
{
type: "text",
text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`,
},
];
} catch (err) {
log.warn(
{
attachmentId: att.id,
error: err instanceof Error ? err.message : String(err),
},
"Error base64 encoding attachment",
);
return [];
}
}),
)
).flat();
}
const existing = messageImageMap.get(att.message_id) ?? [];
existing.push(part);
messageImageMap.set(att.message_id, existing);
} catch (err) {
log.warn(
{
attachmentId: att.id,
error: err instanceof Error ? err.message : String(err),
},
"Error base64 encoding attachment",
);
}
}),
);
const hasImages = messageImageMap.size > 0;
// -------------------------------------------------------------------------
// System prompt — Indonesian-first, English as secondary language.
//
// Core design decisions:
// • Explicitly names the server as a Discord community whose primary
// communication language is Indonesian; English is secondary.
// • Instructs the model to understand Indonesian slang, abbreviations,
// and culturally specific harmful patterns (SARA, hoaks, dll).
// • When images are present, instructs the model to treat each image as
// an integral part of the message that precedes it — not as standalone
// content — so text + image are evaluated together.
// • Strict JSON-only output, no markdown or prose.
// -------------------------------------------------------------------------
const buildSystemPrompt = (correction?: {
error: string;
preview: string;
}): string => {
const imageInstructions = hasImages
? `
## Instruksi Analisis Gambar
Beberapa pesan menyertakan lampiran gambar. Setiap gambar muncul TEPAT SETELAH baris teks pesan yang memilikinya.
Gambar dan teks pesan harus dianalisis sebagai SATU KESATUAN — evaluasilah konten teks DAN gambar secara bersamaan untuk membentuk kesimpulan final.
Jangan pisahkan penilaian gambar dari konteks teks pesannya.
Jika gambar mengandung teks (meme, screenshot), baca dan pertimbangkan teks tersebut sebagai bagian dari konten pesan.
`
: "";
const base = `Kamu adalah asisten moderasi konten untuk server Discord berbahasa Indonesia.
Bahasa utama komunitas ini adalah BAHASA INDONESIA. Bahasa Inggris adalah bahasa sekunder.
## Konteks Server
Ini adalah server Discord komunitas Indonesia. Kamu harus memahami:
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", 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.
- Kalimat ambigu dalam bahasa Indonesia harus ditafsirkan dengan charitable intent kecuali ada indikator kuat sebaliknya.
${imageInstructions}
## Konteks Percakapan
${contextText}
## Format Output
Balas HANYA dengan satu objek JSON valid. Tanpa markdown, tanpa prose, tanpa komentar, tanpa XML.
Struktur wajib:
{
"results": [
{
"message_id": "<ID string PERSIS seperti di input>",
"status": "clean" | "warn" | "flagged",
"flags": [<string array, kosong jika clean>],
"score": <float 0.01.0>,
"analysis": "<penjelasan singkat dalam Bahasa Indonesia, maks 2 kalimat>"
}
]
}
Kriteria status:
- "clean": tidak ada pelanggaran yang terdeteksi
- "warn": konten berpotensi melanggar atau memerlukan perhatian moderator
- "flagged": pelanggaran jelas terdeteksi
Flag yang valid: spam, hate_speech, sara, hoaks, harassment, sexual_content, violence, self_harm, doxxing, scam, misinformation, nsfw_image, gore_image, illegal_content
CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan perlakukan ID sebagai angka — ini snowflake Discord yang bisa kehilangan presisi jika diparse sebagai number.`;
if (correction) {
return `${base}\n\nRESPON SEBELUMNYA GAGAL VALIDASI.\nError: ${correction.error}\nPreview respons tidak valid:\n${correction.preview}\n\nCoba lagi dengan output JSON yang benar sesuai skema di atas.`;
}
return base;
};
// -------------------------------------------------------------------------
// Build the user-turn content.
//
// When images exist, we build an interleaved multipart array:
// [system prompt text] → for each target: [msg text part] → [image(s) for that msg]
//
// This interleaving is the critical fix: it ensures the vision model
// processes each image in direct proximity to its owning message text,
// rather than receiving all images as a disconnected prologue.
// -------------------------------------------------------------------------
type ContentPart = { type: "text"; text: string } | RawImagePart;
let lastParseError: string | null = null;
let lastInvalidContent: string | null = null;
const buildMessageContent = (): MessageContent => {
const correctionPrompt = lastParseError
? `${moderationPrompt}\n\nPrevious response failed validation. Error: ${lastParseError}\nInvalid response preview:\n${lastInvalidContent?.slice(0, 1000) ?? "<empty>"}\n\nRetry with corrected output. Return ONLY one valid JSON object matching the required schema.`
: moderationPrompt;
if (imageParts.length > 0) {
return [
...imageParts,
{
type: "text",
text: correctionPrompt,
},
];
const buildMessageContent = (): string | ContentPart[] => {
const correction = lastParseError
? { error: lastParseError, preview: lastInvalidContent?.slice(0, 800) ?? "<empty>" }
: undefined;
const systemText = buildSystemPrompt(correction);
if (!hasImages) {
// Pure-text path: format all targets in a single block
const messagesBlock = targets
.map((msg) => {
const content = msg.edited_content ?? msg.content;
return `[target] id=${msg.id} user=${msg.username}: ${content}`;
})
.join("\n");
return `${systemText}\n\n## Pesan yang Dianalisis\n${messagesBlock}`;
}
return correctionPrompt;
// Multimodal path: interleave text + images per message
const parts: ContentPart[] = [
{ type: "text", text: `${systemText}\n\n## Pesan yang Dianalisis (dengan lampiran gambar)\n` },
];
for (const msg of targets) {
const content = msg.edited_content ?? msg.content;
const msgText = `[target] id=${msg.id} user=${msg.username}: ${content}`;
parts.push({ type: "text", text: msgText });
// Immediately follow the message text with its images
const imgs = messageImageMap.get(msg.id);
if (imgs && imgs.length > 0) {
for (const img of imgs) {
parts.push(img);
}
// Anchor label after the image(s) so the model's attention window
// links this image block back to the message ID above it
parts.push({
type: "text",
text: `[gambar di atas adalah lampiran dari pesan id=${msg.id}]`,
});
}
}
return parts;
};
let parsed: AnalysisResult[];