feat(moderation): enhance media evidence handling and deferral analysis rejection
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatMediaEvidenceForPrompt } from "./messageMetadata.js";
|
||||||
import type { MessageRecord } from "./types.js";
|
import type { MessageRecord } from "./types.js";
|
||||||
|
|
||||||
export interface ConversationContextInput {
|
export interface ConversationContextInput {
|
||||||
@@ -29,7 +30,9 @@ export function formatMessageForPrompt(
|
|||||||
): string {
|
): string {
|
||||||
const content = msg.edited_content ?? msg.content;
|
const content = msg.edited_content ?? msg.content;
|
||||||
const timestamp = formatTimestamp(msg.created_at);
|
const timestamp = formatTimestamp(msg.created_at);
|
||||||
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`;
|
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
||||||
|
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
||||||
|
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { createChildLogger } from "../logger.js";
|
import { createChildLogger } from "../logger.js";
|
||||||
import { retryWithBackoff } from "../retry.js";
|
import { retryWithBackoff } from "../retry.js";
|
||||||
|
import { extractMessageMediaEvidence } from "./messageMetadata.js";
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
@@ -24,6 +25,13 @@ const ModerationResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const log = createChildLogger("llmModerationClient");
|
const log = createChildLogger("llmModerationClient");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: config.AI_LLM_API_KEY,
|
apiKey: config.AI_LLM_API_KEY,
|
||||||
baseURL: config.AI_LLM_BASE_URL,
|
baseURL: config.AI_LLM_BASE_URL,
|
||||||
@@ -87,18 +95,6 @@ const openai = new OpenAI({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface RawModerationResult {
|
|
||||||
message_id: string;
|
|
||||||
status: string;
|
|
||||||
flags: unknown;
|
|
||||||
score: number;
|
|
||||||
analysis: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawModerationResponse {
|
|
||||||
results: RawModerationResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
|
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +210,12 @@ export function parseModerationResponse(
|
|||||||
|
|
||||||
foundIds.add(finalId);
|
foundIds.add(finalId);
|
||||||
|
|
||||||
|
if (hasDeferralAnalysis(analysis)) {
|
||||||
|
throw new Error(
|
||||||
|
`Deferral analysis is not allowed for message ${finalId}; return a direct moderation decision`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: finalId,
|
messageId: finalId,
|
||||||
status: status as "clean" | "warn" | "flagged",
|
status: status as "clean" | "warn" | "flagged",
|
||||||
@@ -357,7 +359,8 @@ export async function runModerationAnalysis(
|
|||||||
|
|
||||||
// Build a lookup: message_id → list of resolved base64 image parts
|
// Build a lookup: message_id → list of resolved base64 image parts
|
||||||
type RawImagePart = { type: "image_url"; image_url: { url: string } };
|
type RawImagePart = { type: "image_url"; image_url: { url: string } };
|
||||||
type MessageImageMap = Map<string, RawImagePart[]>;
|
type MessageImagePart = RawImagePart & { sourceLabel: string };
|
||||||
|
type MessageImageMap = Map<string, MessageImagePart[]>;
|
||||||
|
|
||||||
// Resolve and download image attachments, grouped by message_id.
|
// Resolve and download image attachments, grouped by message_id.
|
||||||
// Only images whose message_id appears in the full attachment list are kept;
|
// Only images whose message_id appears in the full attachment list are kept;
|
||||||
@@ -447,9 +450,10 @@ export async function runModerationAnalysis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataUrl = `data:${sniffedMime};base64,${imageBytes.toString("base64")}`;
|
const dataUrl = `data:${sniffedMime};base64,${imageBytes.toString("base64")}`;
|
||||||
const part: RawImagePart = {
|
const part: MessageImagePart = {
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: { url: dataUrl },
|
image_url: { url: dataUrl },
|
||||||
|
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = messageImageMap.get(att.message_id) ?? [];
|
const existing = messageImageMap.get(att.message_id) ?? [];
|
||||||
@@ -488,9 +492,10 @@ export async function runModerationAnalysis(
|
|||||||
if (result.type === "image" && result.data && result.mimeType) {
|
if (result.type === "image" && result.data && result.mimeType) {
|
||||||
// Append as an image part
|
// Append as an image part
|
||||||
const dataUrl = `data:${result.mimeType};base64,${result.data.toString("base64")}`;
|
const dataUrl = `data:${result.mimeType};base64,${result.data.toString("base64")}`;
|
||||||
const part: RawImagePart = {
|
const part: MessageImagePart = {
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: { url: dataUrl },
|
image_url: { url: dataUrl },
|
||||||
|
sourceLabel: `[gambar di atas berasal dari link ${url} pada pesan id=${msg.id}]`,
|
||||||
};
|
};
|
||||||
const existing = messageImageMap.get(msg.id) ?? [];
|
const existing = messageImageMap.get(msg.id) ?? [];
|
||||||
existing.push(part);
|
existing.push(part);
|
||||||
@@ -512,6 +517,63 @@ export async function runModerationAnalysis(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const hasImages = messageImageMap.size > 0;
|
const hasImages = messageImageMap.size > 0;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -533,11 +595,11 @@ export async function runModerationAnalysis(
|
|||||||
}): string => {
|
}): string => {
|
||||||
const imageInstructions = hasImages
|
const imageInstructions = hasImages
|
||||||
? `
|
? `
|
||||||
## Instruksi Analisis Gambar
|
## Instruksi Analisis Gambar/Sticker/Embed
|
||||||
Beberapa pesan menyertakan lampiran gambar. Setiap gambar muncul TEPAT SETELAH baris teks pesan yang memilikinya.
|
Beberapa pesan menyertakan gambar, sticker, preview link, atau embed. Setiap media 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.
|
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 gambar dari konteks teks pesannya.
|
Jangan pisahkan penilaian media dari konteks teks pesannya.
|
||||||
Jika gambar mengandung teks (meme, screenshot), baca dan pertimbangkan teks tersebut sebagai bagian dari konten pesan.
|
Jika gambar/sticker/preview mengandung teks (meme, screenshot), baca dan pertimbangkan teks tersebut sebagai bagian dari konten pesan.
|
||||||
`
|
`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
@@ -550,7 +612,9 @@ Ini adalah server Discord komunitas Indonesia. Kamu harus memahami:
|
|||||||
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
${imageInstructions}
|
${imageInstructions}
|
||||||
## Konteks Percakapan
|
## Konteks Percakapan
|
||||||
${contextText}
|
${contextText}
|
||||||
@@ -571,10 +635,14 @@ Struktur wajib:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Kriteria status:
|
Kriteria status:
|
||||||
- "clean": tidak ada pelanggaran yang terdeteksi
|
- "clean": tidak ada pelanggaran yang terdeteksi, atau kasus masih ambigu setelah semua evidence dianalisis
|
||||||
- "warn": konten berpotensi melanggar atau memerlukan perhatian moderator
|
- "warn": risiko ringan yang konkret terdeteksi, misalnya spam borderline atau harassment ringan; BUKAN untuk kurang konteks/perlu admin cek
|
||||||
- "flagged": pelanggaran jelas terdeteksi
|
- "flagged": pelanggaran jelas terdeteksi
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Flag yang valid: spam, hate_speech, sara, hoaks, harassment, sexual_content, violence, self_harm, doxxing, scam, misinformation, nsfw_image, gore_image, illegal_content
|
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.`;
|
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.`;
|
||||||
@@ -618,7 +686,20 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
|
|||||||
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
||||||
const webContext =
|
const webContext =
|
||||||
webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
||||||
return `[target] id=${msg.id} user=${msg.username}: ${content}${webContext}`;
|
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}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
@@ -638,21 +719,32 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
|
|||||||
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
||||||
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
||||||
|
|
||||||
const msgText = `[target] id=${msg.id} user=${msg.username}: ${content}${webContext}`;
|
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}`;
|
||||||
parts.push({ type: "text", text: msgText });
|
parts.push({ type: "text", text: msgText });
|
||||||
|
|
||||||
// Immediately follow the message text with its images
|
// Immediately follow the message text with its images
|
||||||
const imgs = messageImageMap.get(msg.id);
|
const imgs = messageImageMap.get(msg.id);
|
||||||
if (imgs && imgs.length > 0) {
|
if (imgs && imgs.length > 0) {
|
||||||
for (const img of imgs) {
|
for (const img of imgs) {
|
||||||
parts.push(img);
|
parts.push({ type: "image_url", image_url: img.image_url });
|
||||||
|
parts.push({
|
||||||
|
type: "text",
|
||||||
|
text: img.sourceLabel,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// 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}]`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,35 +11,47 @@ export interface MessageLocation {
|
|||||||
channelName: string | null;
|
channelName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RichMessageMetadata {
|
export interface StickerEvidence {
|
||||||
stickers: Array<{
|
id: string;
|
||||||
id: string;
|
name: string;
|
||||||
name: string;
|
url: string;
|
||||||
url: string;
|
format: string | null;
|
||||||
format: string | null;
|
}
|
||||||
}>;
|
|
||||||
embeds: Array<{
|
export interface EmbedEvidence {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
url: string | null;
|
||||||
|
color: number | null;
|
||||||
|
image: string | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
author: {
|
||||||
|
name: string | null;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
color: number | null;
|
iconURL: string | null;
|
||||||
image: string | null;
|
} | null;
|
||||||
thumbnail: string | null;
|
footer: { text: string | null; iconURL: string | null } | null;
|
||||||
author: {
|
fields: Array<{ name: string; value: string; inline: boolean }>;
|
||||||
name: string | null;
|
}
|
||||||
url: string | null;
|
|
||||||
iconURL: string | null;
|
export interface AttachmentEvidence {
|
||||||
} | null;
|
id: string;
|
||||||
footer: { text: string | null; iconURL: string | null } | null;
|
name: string;
|
||||||
fields: Array<{ name: string; value: string; inline: boolean }>;
|
url: string;
|
||||||
}>;
|
contentType: string | null;
|
||||||
attachments: Array<{
|
size: number;
|
||||||
id: string;
|
}
|
||||||
name: string;
|
|
||||||
url: string;
|
export interface MessageMediaEvidence {
|
||||||
contentType: string | null;
|
stickers: StickerEvidence[];
|
||||||
size: number;
|
embeds: EmbedEvidence[];
|
||||||
}>;
|
attachments: AttachmentEvidence[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichMessageMetadata {
|
||||||
|
stickers: Array<StickerEvidence>;
|
||||||
|
embeds: Array<EmbedEvidence>;
|
||||||
|
attachments: Array<AttachmentEvidence>;
|
||||||
author: {
|
author: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -167,6 +179,97 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseRichMessageMetadata(
|
||||||
|
metadata: string | null | undefined,
|
||||||
|
): RichMessageMetadata | null {
|
||||||
|
if (!metadata) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata) as Partial<RichMessageMetadata>;
|
||||||
|
return {
|
||||||
|
stickers: Array.isArray(parsed.stickers) ? parsed.stickers : [],
|
||||||
|
embeds: Array.isArray(parsed.embeds) ? parsed.embeds : [],
|
||||||
|
attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [],
|
||||||
|
author: parsed.author as RichMessageMetadata["author"],
|
||||||
|
member: (parsed.member ?? null) as RichMessageMetadata["member"],
|
||||||
|
channel: parsed.channel as RichMessageMetadata["channel"],
|
||||||
|
reference: (parsed.reference ?? null) as RichMessageMetadata["reference"],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessageMediaEvidence(
|
||||||
|
metadata: string | null | undefined,
|
||||||
|
): MessageMediaEvidence {
|
||||||
|
const parsed = parseRichMessageMetadata(metadata);
|
||||||
|
return {
|
||||||
|
stickers: parsed?.stickers ?? [],
|
||||||
|
embeds: parsed?.embeds ?? [],
|
||||||
|
attachments: parsed?.attachments ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMediaEvidenceForPrompt(
|
||||||
|
metadata: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const evidence = extractMessageMediaEvidence(metadata);
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (evidence.stickers.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`[stickers: ${evidence.stickers
|
||||||
|
.map((sticker) =>
|
||||||
|
[`name=${sticker.name}`, sticker.url ? `url=${sticker.url}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
)
|
||||||
|
.join(" | ")}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.embeds.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`[embeds: ${evidence.embeds
|
||||||
|
.map((embed) =>
|
||||||
|
[
|
||||||
|
embed.title ? `title=${embed.title}` : null,
|
||||||
|
embed.description ? `description=${embed.description}` : null,
|
||||||
|
embed.url ? `url=${embed.url}` : null,
|
||||||
|
embed.image ? `image=${embed.image}` : null,
|
||||||
|
embed.thumbnail ? `thumbnail=${embed.thumbnail}` : null,
|
||||||
|
embed.fields.length > 0
|
||||||
|
? `fields=${embed.fields.map((field) => `${field.name}: ${field.value}`).join("; ")}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
)
|
||||||
|
.join(" | ")}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.attachments.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`[attachments: ${evidence.attachments
|
||||||
|
.map((attachment) =>
|
||||||
|
[
|
||||||
|
`name=${attachment.name}`,
|
||||||
|
attachment.contentType ? `type=${attachment.contentType}` : null,
|
||||||
|
`size=${attachment.size}`,
|
||||||
|
attachment.url ? `url=${attachment.url}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
)
|
||||||
|
.join(" | ")}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export function getDisplayContent(message: Message): string {
|
export function getDisplayContent(message: Message): string {
|
||||||
if (message.content.trim().length > 0) return message.content;
|
if (message.content.trim().length > 0) return message.content;
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,25 @@ describe("parseModerationResponse", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects deferral analysis text", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseModerationResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "m1",
|
||||||
|
status: "warn",
|
||||||
|
flags: [],
|
||||||
|
score: 0.4,
|
||||||
|
analysis: "Kurang konteks, perlu dicek admin.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
["m1"],
|
||||||
|
),
|
||||||
|
).toThrow(/deferral/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("handles missing target ids gracefully", () => {
|
it("handles missing target ids gracefully", () => {
|
||||||
const result = parseModerationResponse(JSON.stringify({ results: [] }), [
|
const result = parseModerationResponse(JSON.stringify({ results: [] }), [
|
||||||
"m1",
|
"m1",
|
||||||
|
|||||||
Reference in New Issue
Block a user