2026-05-21 02:39:28 +07:00
import OpenAI from "openai" ;
2026-05-28 01:09:57 +07:00
import { AbortError } from "p-retry" ;
2026-05-25 23:23:12 +07:00
import { z } from "zod" ;
2026-05-21 12:03:31 +00:00
import { config } from "../config.js" ;
import { createChildLogger } from "../logger.js" ;
import { retryWithBackoff } from "../retry.js" ;
2026-05-29 17:32:04 +07:00
import { extractMessageMediaEvidence } from "./messageMetadata.js" ;
2026-05-21 12:27:07 +00:00
import type {
AnalysisResult ,
AttachmentRecord ,
MessageRecord ,
} from "./types.js" ;
2026-05-28 01:29:31 +07:00
import { extractUrlsFromText , fetchUrlSafely } from "./urlFetcher.js" ;
2026-05-14 19:16:46 +07:00
2026-05-25 23:23:12 +07:00
const ModerationResponseSchema = z . object ({
results : z.array (
z . object ({
message_id : z.union ([ z . string (), z . number ()]). transform ( String ),
status : z.enum ([ "clean" , "warn" , "flagged" ]). catch ( "clean" ),
flags : z.array ( z . string ()). catch ([]),
score : z.number (). catch ( 0 ),
analysis : z.string (). catch ( "" ),
}),
),
});
2026-05-14 19:16:46 +07:00
const log = createChildLogger ( "llmModerationClient" );
2026-05-29 17:32:04 +07:00
const DEFERRAL_ANALYSIS_PATTERN =
/kurang konteks|kekurangan konteks|perlu (dicek|diperiksa|ditinjau).*(admin|moderator)|admin perlu|moderator perlu|tidak bisa menentukan|tidak dapat menentukan|cannot determine|insufficient context/i ;
function hasDeferralAnalysis ( analysis : string ) : boolean {
return DEFERRAL_ANALYSIS_PATTERN . test ( analysis );
}
2026-05-21 02:39:28 +07:00
const openai = new OpenAI ({
apiKey : config.AI_LLM_API_KEY ,
baseURL : config.AI_LLM_BASE_URL ,
maxRetries : 0 ,
2026-05-25 23:23:12 +07:00
timeout : 30000 ,
2026-05-21 02:39:28 +07:00
fetch : async ( url , init ) => {
2026-05-25 23:23:12 +07:00
// Add internal timeout for the global fetch as safety
const controller = new AbortController ();
const timeout = setTimeout (() => controller . abort (), 30000 );
2026-05-26 00:03:01 +07:00
// Override headers to bypass Cloudflare WAF Bot Fight Mode
const headers = new Headers ( init ? . headers );
2026-05-28 01:09:57 +07:00
headers . set (
"User-Agent" ,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ,
);
2026-05-26 00:03:01 +07:00
for ( const key of Array . from ( headers . keys ())) {
if ( key . toLowerCase (). startsWith ( "x-stainless" )) {
headers . delete ( key );
}
}
const fetchInit = { ... init , headers , signal : controller.signal };
2026-05-21 02:39:28 +07:00
2026-05-25 23:23:12 +07:00
try {
const response = await globalThis . fetch ( url , fetchInit );
const body =
typeof response . text === "function"
? await response . text ()
: JSON . stringify ( await response . json ());
let normalizedBody = body ;
if ( response . ok !== false ) {
try {
JSON . parse ( body );
} catch ( error ) {
log . warn (
{
error : error instanceof Error ? error.message : String ( error ),
status : response.status ?? 200 ,
bodyLength : body.length ,
body ,
},
"LLM provider returned malformed JSON response body" ,
);
normalizedBody = JSON . stringify ( extractJson ( body ));
}
2026-05-21 03:52:51 +07:00
}
2026-05-25 23:23:12 +07:00
const headers = new Headers ( response . headers ?? undefined );
headers . set ( "Content-Type" , "application/json" );
headers . delete ( "Content-Length" );
return new Response ( normalizedBody , {
status : response.status ?? 200 ,
headers ,
});
} finally {
clearTimeout ( timeout );
2026-05-21 03:52:51 +07:00
}
2026-05-21 02:39:28 +07:00
},
});
2026-05-14 19:16:46 +07:00
2026-05-18 04:38:30 +07:00
/**
2026-05-21 04:23:01 +07:00
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
2026-05-18 04:38:30 +07:00
*/
export function extractJson ( content : string ) : any {
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g ;
const matches = content . matchAll ( codeBlockRegex );
for ( const match of matches ) {
const codeContent = match [ 1 ]. trim ();
try {
const parsed = JSON . parse ( codeContent );
if ( parsed && typeof parsed === "object" ) {
return parsed ;
}
2026-05-21 04:23:01 +07:00
} catch ( _ ) {}
2026-05-18 04:38:30 +07:00
}
2026-05-21 04:23:01 +07:00
for ( let start = 0 ; start < content . length ; start ++ ) {
const firstChar = content [ start ];
if ( firstChar !== "{" && firstChar !== "[" ) continue ;
2026-05-18 04:38:30 +07:00
2026-05-21 04:23:01 +07:00
const stack = [ firstChar ];
let inString = false ;
let escaped = false ;
for ( let i = start + 1 ; i < content . length ; i ++ ) {
const char = content [ i ];
if ( inString ) {
if ( escaped ) {
escaped = false ;
} else if ( char === "\\" ) {
escaped = true ;
} else if ( char === '"' ) {
inString = false ;
}
continue ;
}
if ( char === '"' ) {
inString = true ;
continue ;
}
if ( char === "{" || char === "[" ) {
stack . push ( char );
continue ;
}
const last = stack [ stack . length - 1 ];
if (( char === "}" && last === "{" ) || ( char === "]" && last === "[" )) {
stack . pop ();
if ( stack . length === 0 ) {
const candidate = content . slice ( start , i + 1 );
try {
const parsed = JSON . parse ( candidate );
if ( parsed && typeof parsed === "object" ) {
return parsed ;
}
} catch ( _ ) {}
break ;
2026-05-18 04:38:30 +07:00
}
}
}
}
throw new Error ( "No JSON object found in response" );
}
2026-05-14 19:16:46 +07:00
export function parseModerationResponse (
content : string ,
targetIds : string [],
) : AnalysisResult [] {
2026-05-25 22:14:05 +07:00
let parsed : any ;
try {
parsed = JSON . parse ( content );
} catch ( e ) {
parsed = extractJson ( content );
}
2026-05-18 07:12:41 +07:00
if ( Array . isArray ( parsed )) {
parsed = { results : parsed };
2026-05-18 23:46:51 +07:00
} else if ( parsed && typeof parsed === "object" && ! ( "results" in parsed )) {
2026-05-25 22:14:05 +07:00
const arrayKey = Object . keys ( parsed ). find (( key ) =>
Array . isArray (( parsed as any )[ key ]),
);
if ( arrayKey ) {
parsed . results = ( parsed as any )[ arrayKey ];
2026-05-18 23:46:51 +07:00
} else {
2026-05-25 22:14:05 +07:00
parsed = { results : [ parsed ] };
2026-05-18 23:46:51 +07:00
}
2026-05-18 07:12:41 +07:00
}
2026-05-14 19:16:46 +07:00
2026-05-25 23:23:12 +07:00
const parseResult = ModerationResponseSchema . safeParse ( parsed );
if ( ! parseResult . success ) {
throw new Error ( `Zod validation failed: ${ parseResult . error . message } ` );
2026-05-14 19:16:46 +07:00
}
2026-05-25 23:23:12 +07:00
const response = parseResult . data ;
2026-05-14 19:16:46 +07:00
const foundIds = new Set < string >();
const targetIdSet = new Set ( targetIds );
2026-05-25 23:23:12 +07:00
const results : ( AnalysisResult | null )[] = response . results . map (( result ) => {
const { message_id , status , flags , score , analysis } = result ;
const finalId = message_id . trim ();
2026-05-14 19:16:46 +07:00
2026-05-25 23:23:12 +07:00
if ( ! targetIdSet . has ( finalId )) {
return null ;
}
2026-05-14 19:16:46 +07:00
2026-05-25 23:23:12 +07:00
if ( foundIds . has ( finalId )) {
return null ; // Ignore duplicates safely
}
2026-05-14 19:16:46 +07:00
2026-05-25 23:23:12 +07:00
foundIds . add ( finalId );
2026-05-14 19:16:46 +07:00
2026-05-29 17:32:04 +07:00
if ( hasDeferralAnalysis ( analysis )) {
throw new Error (
`Deferral analysis is not allowed for message ${ finalId } ; return a direct moderation decision` ,
);
}
2026-05-25 23:23:12 +07:00
return {
messageId : finalId ,
status : status as "clean" | "warn" | "flagged" ,
flags ,
score : Math.max ( 0 , Math . min ( 1 , score )),
analysis ,
};
});
2026-05-14 19:16:46 +07:00
2026-05-16 23:47:50 +07:00
const filteredResults = results . filter (
( r ) : r is AnalysisResult => r !== null ,
);
2026-05-14 19:16:46 +07:00
const missingIds = targetIds . filter (( id ) => ! foundIds . has ( id ));
if ( missingIds . length > 0 ) {
2026-05-18 06:39:12 +07:00
log . warn (
{ missingIds , foundCount : foundIds.size , totalCount : targetIds.length },
2026-05-18 07:07:27 +07:00
"Some target IDs missing in response - marking as incomplete" ,
2026-05-18 06:39:12 +07:00
);
for ( const missingId of missingIds ) {
filteredResults . push ({
messageId : missingId ,
2026-05-21 02:39:28 +07:00
status : "error" ,
flags : [ "analysis_incomplete" ],
2026-05-18 06:39:12 +07:00
score : 0 ,
analysis : "Analysis incomplete - LLM did not process this message" ,
});
}
2026-05-14 19:16:46 +07:00
}
2026-05-16 23:47:50 +07:00
return filteredResults ;
2026-05-14 19:16:46 +07:00
}
interface ModerationInput {
targets : MessageRecord [];
contextText : string ;
2026-05-17 23:56:04 +07:00
attachments? : AttachmentRecord [];
2026-05-14 19:16:46 +07:00
}
interface ModerationOutput {
results : AnalysisResult [];
raw : unknown ;
}
2026-05-21 23:44:03 +07:00
/**
* Sniff the first bytes of a buffer to determine if it is a supported image
* format. Returns the canonical MIME type string on success, or null if the
* bytes are not a recognizable image.
*
* Supported probes (in order):
* - JPEG: FF D8 FF
* - PNG: 89 50 4E 47 0D 0A 1A 0A
* - GIF: 47 49 46 38 (GIF8)
* - WebP: 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 (RIFF....WEBP)
* - AVIF / HEIF: 4-byte big-endian size + 66 74 79 70 (ftyp ISO base-media box)
*/
function sniffImageMimeType ( buf : Buffer ) : string | null {
if ( buf . length < 12 ) return null ;
// JPEG
if ( buf [ 0 ] === 0xff && buf [ 1 ] === 0xd8 && buf [ 2 ] === 0xff ) {
return "image/jpeg" ;
}
// PNG
if (
buf [ 0 ] === 0x89 &&
buf [ 1 ] === 0x50 &&
buf [ 2 ] === 0x4e &&
buf [ 3 ] === 0x47 &&
buf [ 4 ] === 0x0d &&
buf [ 5 ] === 0x0a &&
buf [ 6 ] === 0x1a &&
buf [ 7 ] === 0x0a
) {
return "image/png" ;
}
// GIF
if (
buf [ 0 ] === 0x47 &&
buf [ 1 ] === 0x49 &&
buf [ 2 ] === 0x46 &&
buf [ 3 ] === 0x38
) {
return "image/gif" ;
}
// WebP: RIFF????WEBP
if (
buf [ 0 ] === 0x52 &&
buf [ 1 ] === 0x49 &&
buf [ 2 ] === 0x46 &&
buf [ 3 ] === 0x46 &&
buf [ 8 ] === 0x57 &&
buf [ 9 ] === 0x45 &&
buf [ 10 ] === 0x42 &&
buf [ 11 ] === 0x50
) {
return "image/webp" ;
}
// AVIF / HEIF: ISO base media file format — ftyp box at offset 4
if (
buf . length >= 12 &&
buf [ 4 ] === 0x66 &&
buf [ 5 ] === 0x74 &&
buf [ 6 ] === 0x79 &&
buf [ 7 ] === 0x70
) {
const brand = buf . subarray ( 8 , 12 ). toString ( "ascii" );
if ( brand . startsWith ( "avif" ) || brand . startsWith ( "avis" )) {
return "image/avif" ;
}
if (
brand . startsWith ( "mif1" ) ||
brand . startsWith ( "heic" ) ||
brand . startsWith ( "heis" )
) {
return "image/heic" ;
}
}
return null ;
}
2026-05-14 19:16:46 +07:00
/**
* Runs LLM-based moderation analysis on messages.
* POSTs to AI_LLM_BASE_URL with auth bearer token.
*/
export async function runModerationAnalysis (
input : ModerationInput ,
) : Promise < ModerationOutput > {
2026-05-17 23:56:04 +07:00
const { targets , contextText , attachments } = input ;
2026-05-14 19:16:46 +07:00
if ( ! targets . length ) {
throw new Error ( "No targets provided for analysis" );
}
const targetIds = targets . map (( t ) => t . id );
2026-05-22 01:04:00 +07:00
// Build a lookup: message_id → list of resolved base64 image parts
type RawImagePart = { type : "image_url" ; image_url : { url : string } };
2026-05-29 17:32:04 +07:00
type MessageImagePart = RawImagePart & { sourceLabel : string };
type MessageImageMap = Map < string , MessageImagePart [] >;
2026-05-14 19:16:46 +07:00
2026-05-22 01:04:00 +07:00
// 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 ;
2026-05-21 02:56:41 +07:00
2026-05-18 04:38:30 +07:00
const targetIdSet = new Set ( targets . map (( t ) => t . id ));
2026-05-22 01:04:00 +07:00
const candidateAttachments = ( attachments ?? [])
2026-05-25 23:23:12 +07:00
. filter (
( att ) => getAttachmentImageUrl ( att ) && att . type . startsWith ( "image/" ),
)
2026-05-18 04:38:30 +07:00
. sort (( a , b ) => {
2026-05-22 01:04:00 +07:00
// Target-message attachments always come first so they consume the cap first
2026-05-18 04:38:30 +07:00
const aIsTarget = targetIdSet . has ( a . message_id ) ? 1 : 0 ;
const bIsTarget = targetIdSet . has ( b . message_id ) ? 1 : 0 ;
2026-05-22 01:04:00 +07:00
if ( aIsTarget !== bIsTarget ) return bIsTarget - aIsTarget ;
// Within the same priority tier, newest first
return b . created_at - a . created_at ;
2026-05-18 04:38:30 +07:00
})
2026-05-22 01:04:00 +07:00
. slice ( 0 , 8 ); // Hard cap — some vision APIs (Nemotron, Omni) reject >8 images
2026-05-17 23:56:04 +07:00
2026-05-22 01:04:00 +07:00
const messageImageMap : MessageImageMap = new Map ();
2026-05-21 04:23:01 +07:00
2026-05-22 01:04:00 +07:00
await Promise . all (
candidateAttachments . map ( async ( att ) => {
const urlToUse = getAttachmentImageUrl ( att );
if ( ! urlToUse ) return ;
2026-05-21 04:23:01 +07:00
2026-05-25 23:23:12 +07:00
const controller = new AbortController ();
const timeoutId = setTimeout (() => controller . abort (), 15000 );
2026-05-22 01:04:00 +07:00
try {
log . info (
{ attachmentId : att.id , messageId : att.message_id , url : urlToUse },
"Downloading attachment for base64 encoding" ,
);
2026-05-25 23:23:12 +07:00
const res = await fetch ( urlToUse , { signal : controller.signal });
2026-05-22 01:04:00 +07:00
if ( ! res . ok ) {
log . warn (
{ attachmentId : att.id , status : res.status , url : urlToUse },
"Failed to fetch attachment image — non-2xx status" ,
);
return ;
}
2026-05-21 23:44:03 +07:00
2026-05-25 23:23:12 +07:00
if ( ! res . body ) return ;
let totalBytes = 0 ;
const chunks : Uint8Array [] = [];
const reader = res . body . getReader ();
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
if ( value ) {
totalBytes += value . length ;
if ( totalBytes > 10 * 1024 * 1024 ) {
log . warn (
{ attachmentId : att.id },
"Attachment exceeded 10MB limit, aborting stream" ,
);
reader . cancel ();
return ;
}
chunks . push ( value );
}
2026-05-25 22:14:05 +07:00
}
2026-05-25 23:23:12 +07:00
const imageBytes = Buffer . concat ( chunks );
2026-05-22 01:04:00 +07:00
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 ;
}
2026-05-21 23:44:03 +07:00
2026-05-22 01:04:00 +07:00
const dataUrl = `data: ${ sniffedMime } ;base64, ${ imageBytes . toString ( "base64" ) } ` ;
2026-05-29 17:32:04 +07:00
const part : MessageImagePart = {
2026-05-25 23:23:12 +07:00
type : "image_url" ,
image_url : { url : dataUrl },
2026-05-29 17:32:04 +07:00
sourceLabel : `[gambar di atas adalah attachment ${ att . filename } dari pesan id= ${ att . message_id } ]` ,
2026-05-25 23:23:12 +07:00
};
2026-05-21 04:23:01 +07:00
2026-05-22 01:04:00 +07:00
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" ,
);
2026-05-25 23:23:12 +07:00
} finally {
clearTimeout ( timeoutId );
2026-05-22 01:04:00 +07:00
}
}),
);
2026-05-28 01:29:31 +07:00
// --- Fetch URLs found in target messages ---
// To avoid slowing down the pipeline too much, we limit to 3 URLs per message.
const messageWebTextMap = new Map < string , string [] >();
await Promise . all (
targets . map ( async ( msg ) => {
const content = msg . edited_content ?? msg . content ;
const urls = extractUrlsFromText ( content ). slice ( 0 , 3 );
if ( urls . length === 0 ) return ;
const webTexts : string [] = [];
await Promise . all (
urls . map ( async ( url ) => {
const result = await fetchUrlSafely ( url );
if ( result . type === "image" && result . data && result . mimeType ) {
// Append as an image part
const dataUrl = `data: ${ result . mimeType } ;base64, ${ result . data . toString ( "base64" ) } ` ;
2026-05-29 17:32:04 +07:00
const part : MessageImagePart = {
2026-05-28 01:29:31 +07:00
type : "image_url" ,
image_url : { url : dataUrl },
2026-05-29 17:32:04 +07:00
sourceLabel : `[gambar di atas berasal dari link ${ url } pada pesan id= ${ msg . id } ]` ,
2026-05-28 01:29:31 +07:00
};
const existing = messageImageMap . get ( msg . id ) ?? [];
existing . push ( part );
messageImageMap . set ( msg . id , existing );
} else if ( result . type === "text" && result . textContent ) {
webTexts . push ( `[Isi Web dari ${ url } ]: ${ result . textContent } ` );
} else if ( result . type === "error" ) {
log . debug (
{ url , error : result.error },
"Failed to fetch URL for moderation context" ,
);
}
}),
);
if ( webTexts . length > 0 ) {
messageWebTextMap . set ( msg . id , webTexts );
}
}),
);
2026-05-29 17:32:04 +07:00
const mediaImageCandidates = targets . flatMap (( msg ) => {
const evidence = extractMessageMediaEvidence ( msg . metadata );
return [
... evidence . stickers
. filter (( sticker ) => sticker . url )
. map (( sticker ) => ({
messageId : msg.id ,
url : sticker.url ,
label : `[gambar di atas adalah sticker " ${ sticker . name } " dari pesan id= ${ msg . id } ]` ,
})),
... evidence . embeds . flatMap (( embed ) =>
[
embed . image
? {
messageId : msg.id ,
url : embed.image ,
label : `[gambar di atas berasal dari embed image pada pesan id= ${ msg . id } ]` ,
}
: null ,
embed . thumbnail
? {
messageId : msg.id ,
url : embed.thumbnail ,
label : `[gambar di atas berasal dari embed thumbnail pada pesan id= ${ msg . id } ]` ,
}
: null ,
]. filter (
( candidate ) : candidate is { messageId : string ; url : string ; label : string } =>
candidate !== null ,
),
),
];
});
const remainingImageSlots = Math . max (
0 ,
8 - Array . from ( messageImageMap . values ()). reduce (( sum , imgs ) => sum + imgs . length , 0 ),
);
await Promise . all (
mediaImageCandidates . slice ( 0 , remainingImageSlots ). map ( async ( candidate ) => {
const result = await fetchUrlSafely ( candidate . url );
if ( result . type !== "image" || ! result . data || ! result . mimeType ) return ;
const part : MessageImagePart = {
type : "image_url" ,
image_url : {
url : `data: ${ result . mimeType } ;base64, ${ result . data . toString ( "base64" ) } ` ,
},
sourceLabel : candidate.label ,
};
const existing = messageImageMap . get ( candidate . messageId ) ?? [];
existing . push ( part );
messageImageMap . set ( candidate . messageId , existing );
}),
);
2026-05-22 01:04:00 +07:00
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
? `
2026-05-29 17:32:04 +07:00
## Instruksi Analisis Gambar/Sticker/Embed
Beberapa pesan menyertakan gambar, sticker, preview link, atau embed. Setiap media muncul TEPAT SETELAH baris teks pesan yang memilikinya.
Media dan teks pesan harus dianalisis sebagai SATU KESATUAN — evaluasilah konten teks, gambar, sticker, dan preview link secara bersamaan untuk membentuk kesimpulan final.
Jangan pisahkan penilaian media dari konteks teks pesannya.
Jika gambar/sticker/preview mengandung teks (meme, screenshot), baca dan pertimbangkan teks tersebut sebagai bagian dari konten pesan.
2026-05-22 01:04:00 +07:00
`
: "" ;
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.
2026-05-29 17:32:04 +07:00
- 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.
2026-05-22 01:04:00 +07:00
${ 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.0– 1.0>,
"analysis": "<penjelasan singkat dalam Bahasa Indonesia, maks 2 kalimat>"
}
]
}
Kriteria status:
2026-05-29 17:32:04 +07:00
- "clean": tidak ada pelanggaran yang terdeteksi, atau kasus masih ambigu setelah semua evidence dianalisis
- "warn": risiko ringan yang konkret terdeteksi, misalnya spam borderline atau harassment ringan; BUKAN untuk kurang konteks/perlu admin cek
2026-05-22 01:04:00 +07:00
- "flagged": pelanggaran jelas terdeteksi
2026-05-29 17:32:04 +07:00
Larangan output analysis:
- Jangan tulis "kurang konteks", "perlu dicek admin", "perlu moderator periksa", "tidak bisa menentukan", atau frasa deferral sejenis.
- Jika evidence tidak cukup kuat untuk pelanggaran, status harus "clean" dan analysis menjelaskan alasan langsung.
2026-05-22 01:04:00 +07:00
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 ;
2026-05-17 23:56:04 +07:00
2026-05-21 04:23:01 +07:00
let lastParseError : string | null = null ;
let lastInvalidContent : string | null = null ;
2026-05-14 19:16:46 +07:00
2026-05-22 01:04:00 +07:00
const buildMessageContent = () : string | ContentPart [] => {
const correction = lastParseError
2026-05-25 23:23:12 +07:00
? {
error : lastParseError ,
preview : lastInvalidContent?.slice ( 0 , 800 ) ?? "<empty>" ,
}
2026-05-22 01:04:00 +07:00
: 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 ;
2026-05-28 01:29:31 +07:00
const webTexts = messageWebTextMap . get ( msg . id ) ?? [];
const webContext =
webTexts . length > 0 ? ` \ n ${ webTexts . join ( "\n" ) } ` : "" ;
2026-05-29 17:32:04 +07:00
const mediaEvidence = extractMessageMediaEvidence ( msg . metadata );
const mediaContext = [
mediaEvidence . stickers . length > 0
? `[sticker evidence: ${ mediaEvidence . stickers . map (( s ) => ` ${ s . name } ( ${ s . url } )` ). join ( " | " ) } ]`
: null ,
mediaEvidence . embeds . length > 0
? `[embed evidence: ${ mediaEvidence . embeds
. map (( e ) => [ e . title , e . description , e . url , e . image , e . thumbnail ]. filter ( Boolean ). join ( " | " ))
. join ( " || " ) } ]`
: null ,
]
. filter ( Boolean )
. join ( " " );
return `[target] id= ${ msg . id } user= ${ msg . username } : ${ content }${ mediaContext ? ` ${ mediaContext } ` : "" }${ webContext } ` ;
2026-05-22 01:04:00 +07:00
})
. join ( "\n" );
return ` ${ systemText } \ n \ n## Pesan yang Dianalisis \ n ${ messagesBlock } ` ;
2026-05-21 04:23:01 +07:00
}
2026-05-14 19:16:46 +07:00
2026-05-22 01:04:00 +07:00
// Multimodal path: interleave text + images per message
const parts : ContentPart [] = [
2026-05-25 23:23:12 +07:00
{
type : "text" ,
text : ` ${ systemText } \ n \ n## Pesan yang Dianalisis (dengan lampiran gambar) \ n` ,
},
2026-05-22 01:04:00 +07:00
];
for ( const msg of targets ) {
const content = msg . edited_content ?? msg . content ;
2026-05-28 01:29:31 +07:00
const webTexts = messageWebTextMap . get ( msg . id ) ?? [];
const webContext = webTexts . length > 0 ? ` \ n ${ webTexts . join ( "\n" ) } ` : "" ;
2026-05-29 17:32:04 +07:00
const mediaEvidence = extractMessageMediaEvidence ( msg . metadata );
const mediaContext = [
mediaEvidence . stickers . length > 0
? `[sticker evidence: ${ mediaEvidence . stickers . map (( s ) => ` ${ s . name } ( ${ s . url } )` ). join ( " | " ) } ]`
: null ,
mediaEvidence . embeds . length > 0
? `[embed evidence: ${ mediaEvidence . embeds
. map (( e ) => [ e . title , e . description , e . url , e . image , e . thumbnail ]. filter ( Boolean ). join ( " | " ))
. join ( " || " ) } ]`
: null ,
]
. filter ( Boolean )
. join ( " " );
const msgText = `[target] id= ${ msg . id } user= ${ msg . username } : ${ content }${ mediaContext ? ` ${ mediaContext } ` : "" }${ webContext } ` ;
2026-05-22 01:04:00 +07:00
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 ) {
2026-05-29 17:32:04 +07:00
parts . push ({ type : "image_url" , image_url : img.image_url });
parts . push ({
type : "text" ,
text : img.sourceLabel ,
});
2026-05-22 01:04:00 +07:00
}
}
}
return parts ;
2026-05-21 04:23:01 +07:00
};
2026-05-14 19:16:46 +07:00
2026-05-18 07:07:27 +07:00
let parsed : AnalysisResult [];
2026-05-21 04:23:01 +07:00
let result : OpenAI.Chat.Completions.ChatCompletion | null = null ;
2026-05-18 07:07:27 +07:00
try {
2026-05-21 04:23:01 +07:00
const analysis = await retryWithBackoff (
async () => {
try {
2026-05-28 01:09:57 +07:00
const completion = await openai . chat . completions . create ({
model : config.AI_LLM_MODEL ,
messages : [
{
role : "user" ,
content : buildMessageContent (),
},
],
temperature : 0.2 ,
top_p : 0.95 ,
max_tokens : 16384 ,
response_format : {
type : "json_object" ,
2026-05-21 12:03:31 +00:00
},
2026-05-28 01:09:57 +07:00
stream : false ,
chat_template_kwargs : { enable_thinking : false },
reasoning_budget : 0 ,
} as OpenAI . Chat . Completions . ChatCompletionCreateParamsNonStreaming );
if (
! completion . choices ||
! Array . isArray ( completion . choices ) ||
! completion . choices [ 0 ]
) {
throw new Error ( "Invalid LLM response structure" );
}
const content = completion . choices [ 0 ]. message ? . content ;
if ( ! content ) {
throw new Error ( "No content in LLM response" );
}
try {
return {
parsed : parseModerationResponse ( content , targetIds ),
result : completion ,
};
} catch ( parseError ) {
lastParseError =
parseError instanceof Error
? parseError.message
: String ( parseError );
lastInvalidContent = content ;
log . warn (
{
error : lastParseError ,
contentLength : content.length ,
contentPreview : content.substring ( 0 , 1000 ),
fullContent : content ,
targetIds ,
model : config.AI_LLM_MODEL ,
},
"Failed to parse moderation response from LLM" ,
);
throw parseError ;
}
} catch ( apiError : any ) {
// Immediately abort retries on rate limits or auth errors so the
// message can return to the DB queue instead of bursting retries.
if (
apiError ? . status === 429 ||
apiError ? . status === 401 ||
apiError ? . status === 403
) {
throw new AbortError ( apiError );
}
throw apiError ;
2026-05-21 04:23:01 +07:00
}
},
{
retries : 3 ,
minTimeout : 1000 ,
maxTimeout : 10000 ,
logger : log ,
},
);
parsed = analysis . parsed ;
result = analysis . result ;
2026-05-18 07:07:27 +07:00
} catch ( parseError ) {
2026-05-21 04:23:01 +07:00
if ( ! lastInvalidContent ) {
throw parseError ;
}
2026-05-19 02:49:55 +07:00
const errorMsg =
parseError instanceof Error ? parseError.message : String ( parseError );
2026-05-21 04:23:01 +07:00
const content : string = lastInvalidContent ;
2026-05-25 23:23:12 +07:00
2026-05-25 22:14:05 +07:00
log . error (
{
error : errorMsg ,
contentLength : content.length ,
contentPreview : content.substring ( 0 , 500 ),
fullContent : content ,
targetIds ,
model : config.AI_LLM_MODEL ,
timestamp : new Date (). toISOString (),
},
"Robust Fallback: Failed to parse moderation response. Marking all targets as analysis errors." ,
);
parsed = targetIds . map (( id ) => ({
messageId : id ,
status : "error" ,
flags : [ "analysis_parse_failed" ],
score : 0 ,
analysis : `Parsing failed: ${ errorMsg } .` ,
}));
2026-05-18 07:07:27 +07:00
}
2026-05-14 19:16:46 +07:00
log . info (
{
targetCount : targets.length ,
resultCount : parsed.length ,
},
"Moderation analysis complete" ,
);
return {
results : parsed ,
raw : result ,
};
}