feat: add sticker context to LLM prompts + sticker image cache
- New sticker-specific vision prompt that tells LLM stickers are cartoon/meme art, not real photos - New text-only warning for stickers that fail to download — prevents flagging based on name alone - Updated system prompt with dedicated sticker guidance section (looser standards for cartoon content) - Filesystem-backed sticker cache (keyed by name, 7-day TTL, 100MB max with LRU eviction) - Config: STICKER_CACHE_DIR and STICKER_CACHE_MAX_SIZE_MB with defaults - Updated .env.example with auto-delete + sticker config docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
202ea311a5
commit
4fa7875b80
+3
-1
@@ -136,11 +136,13 @@ const configSchema = z
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.50),
|
||||
AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.5),
|
||||
AUTO_DELETE_ALLOWED_SEVERITIES: z.string().default("critical,high,medium"),
|
||||
AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""),
|
||||
AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""),
|
||||
AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""),
|
||||
STICKER_CACHE_DIR: z.string().default("./sticker-cache"),
|
||||
STICKER_CACHE_MAX_SIZE_MB: z.coerce.number().int().positive().default(100),
|
||||
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
|
||||
@@ -6,6 +6,16 @@ import { createChildLogger } from "../logger.js";
|
||||
import { retryWithBackoff } from "../retry.js";
|
||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||
import { extractMessageMediaEvidence } from "./messageMetadata.js";
|
||||
import {
|
||||
buildStickerTextOnlyWarning,
|
||||
buildStickerVisionPrompt,
|
||||
} from "./stickerPrompt.js";
|
||||
import {
|
||||
getStickerFromCache,
|
||||
initStickerCache,
|
||||
isStickerCacheReady,
|
||||
setStickerInCache,
|
||||
} from "./stickerCache.js";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
AttachmentRecord,
|
||||
@@ -50,7 +60,10 @@ function hasDeferralAnalysis(analysis: string): boolean {
|
||||
}
|
||||
|
||||
function clampScore(value: number | undefined, fallback = 0): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(value) ? (value as number) : fallback));
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(1, Number.isFinite(value) ? (value as number) : fallback),
|
||||
);
|
||||
}
|
||||
|
||||
function deriveSeverity(
|
||||
@@ -272,7 +285,8 @@ export function parseModerationResponse(
|
||||
|
||||
const normalizedScore = clampScore(score);
|
||||
const normalizedConfidence = clampScore(confidence, normalizedScore);
|
||||
const normalizedSeverity = severity ?? deriveSeverity(status, normalizedScore);
|
||||
const normalizedSeverity =
|
||||
severity ?? deriveSeverity(status, normalizedScore);
|
||||
|
||||
return {
|
||||
messageId: finalId,
|
||||
@@ -284,7 +298,8 @@ export function parseModerationResponse(
|
||||
severity: normalizedSeverity,
|
||||
confidence: normalizedConfidence,
|
||||
recommendedAction:
|
||||
recommended_action ?? deriveRecommendedAction(status, normalizedSeverity),
|
||||
recommended_action ??
|
||||
deriveRecommendedAction(status, normalizedSeverity),
|
||||
policyVersion: policy_version ?? "default-2026-05-30",
|
||||
evidence: evidence ?? [],
|
||||
};
|
||||
@@ -426,6 +441,19 @@ export async function runModerationAnalysis(
|
||||
throw new Error("No targets provided for analysis");
|
||||
}
|
||||
|
||||
// Lazy init sticker cache on first run
|
||||
if (!isStickerCacheReady()) {
|
||||
await initStickerCache({
|
||||
cacheDir: config.STICKER_CACHE_DIR,
|
||||
maxSizeBytes: config.STICKER_CACHE_MAX_SIZE_MB * 1024 * 1024,
|
||||
}).catch((err) => {
|
||||
log.warn(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Sticker cache init failed — continuing without cache",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const targetIds = targets.map((t) => t.id);
|
||||
|
||||
// Build a lookup: message_id → list of resolved base64 image parts
|
||||
@@ -433,6 +461,7 @@ export async function runModerationAnalysis(
|
||||
type: "image_url";
|
||||
image_url: { url: string };
|
||||
sourceLabel: string;
|
||||
stickerName?: string;
|
||||
};
|
||||
type MessageImageMap = Map<string, MessageImagePart[]>;
|
||||
|
||||
@@ -600,6 +629,7 @@ export async function runModerationAnalysis(
|
||||
messageId: msg.id,
|
||||
url: sticker.url,
|
||||
label: `[gambar di atas adalah sticker "${sticker.name}" dari pesan id=${msg.id}]`,
|
||||
stickerName: sticker.name,
|
||||
})),
|
||||
...evidence.embeds.flatMap((embed) =>
|
||||
[
|
||||
@@ -618,8 +648,14 @@ export async function runModerationAnalysis(
|
||||
}
|
||||
: null,
|
||||
].filter(
|
||||
(candidate): candidate is { messageId: string; url: string; label: string } =>
|
||||
candidate !== null,
|
||||
(
|
||||
candidate,
|
||||
): candidate is {
|
||||
messageId: string;
|
||||
url: string;
|
||||
label: string;
|
||||
stickerName?: string;
|
||||
} => candidate !== null,
|
||||
),
|
||||
),
|
||||
];
|
||||
@@ -627,25 +663,82 @@ export async function runModerationAnalysis(
|
||||
|
||||
const remainingImageSlots = Math.max(
|
||||
0,
|
||||
8 - Array.from(messageImageMap.values()).reduce((sum, imgs) => sum + imgs.length, 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;
|
||||
mediaImageCandidates
|
||||
.slice(0, remainingImageSlots)
|
||||
.map(async (candidate) => {
|
||||
// --- Sticker cache check ---
|
||||
if (candidate.stickerName && isStickerCacheReady()) {
|
||||
try {
|
||||
const cached = await getStickerFromCache(candidate.stickerName);
|
||||
if (cached) {
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${cached.mimeType};base64,${cached.base64}`,
|
||||
},
|
||||
sourceLabel: candidate.label,
|
||||
stickerName: candidate.stickerName,
|
||||
};
|
||||
const existing = messageImageMap.get(candidate.messageId) ?? [];
|
||||
existing.push(part);
|
||||
messageImageMap.set(candidate.messageId, existing);
|
||||
log.debug(
|
||||
{ stickerName: candidate.stickerName },
|
||||
"Sticker cache HIT — skipped fetch",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
{
|
||||
stickerName: candidate.stickerName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Sticker cache read error — falling back to fetch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}),
|
||||
// --- Cache miss or non-sticker: fetch normally ---
|
||||
const result = await fetchUrlSafely(candidate.url);
|
||||
if (result.type !== "image" || !result.data || !result.mimeType) return;
|
||||
|
||||
const base64 = result.data.toString("base64");
|
||||
|
||||
// Cache on success (sticker only)
|
||||
if (candidate.stickerName) {
|
||||
setStickerInCache(
|
||||
candidate.stickerName,
|
||||
base64,
|
||||
result.mimeType,
|
||||
).catch((err) => {
|
||||
log.warn(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Failed to cache sticker — continuing without cache",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${result.mimeType};base64,${base64}`,
|
||||
},
|
||||
sourceLabel: candidate.label,
|
||||
stickerName: candidate.stickerName,
|
||||
};
|
||||
const existing = messageImageMap.get(candidate.messageId) ?? [];
|
||||
existing.push(part);
|
||||
messageImageMap.set(candidate.messageId, existing);
|
||||
}),
|
||||
);
|
||||
|
||||
const analyzeSingleMediaImage = async (
|
||||
@@ -661,7 +754,9 @@ export async function runModerationAnalysis(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Analisis media Discord berikut sebagai evidence moderasi. ${image.sourceLabel}\nJelaskan isi visual, teks yang terlihat, konteks risiko, dan apakah ada indikasi spam, scam, SARA, harassment, sexual content, violence, self-harm, doxxing, NSFW, gore, atau illegal content. Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek; berikan observasi langsung dari media.`,
|
||||
text: image.stickerName
|
||||
? buildStickerVisionPrompt(image.stickerName, messageId)
|
||||
: `Analisis media Discord berikut sebagai evidence moderasi. ${image.sourceLabel}\nJelaskan isi visual, teks yang terlihat, konteks risiko, dan apakah ada indikasi spam, scam, SARA, harassment, sexual content, violence, self-harm, doxxing, NSFW, gore, atau illegal content. Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek; berikan observasi langsung dari media.`,
|
||||
},
|
||||
{ type: "image_url", image_url: image.image_url },
|
||||
],
|
||||
@@ -703,7 +798,6 @@ export async function runModerationAnalysis(
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// System prompt — Indonesian-first, English as secondary language.
|
||||
//
|
||||
@@ -725,7 +819,16 @@ export async function runModerationAnalysis(
|
||||
## Instruksi Analisis Media
|
||||
Gambar, sticker, embed image, preview link, dan attachment sudah dianalisis lewat request media terpisah sebelum batch utama.
|
||||
Gunakan baris "Media analysis" sebagai evidence visual utama dalam keputusan moderasi batch ini.
|
||||
Sticker wajib diperlakukan sebagai image evidence, bukan sekadar nama sticker.
|
||||
|
||||
## Panduan Khusus Sticker
|
||||
- Sticker Discord adalah media kartun/meme/ilustrasi, BUKAN foto atau video nyata.
|
||||
- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan.
|
||||
- Gambar sticker bisa menampilkan adegan kartun yang terlihat "keras" (tokoh kartun menginjak sesuatu, ledakan komik, senjata kartun) — itu SENI KARTUN, bukan dokumentasi kekerasan nyata.
|
||||
- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat", "Bom atom", dll) adalah konteks satir/humor. JANGAN flag "violence", "harassment", atau "sara" berdasarkan nama sticker saja tanpa melihat gambar.
|
||||
- Jika sticker evidence hanya tersedia sebagai nama (gambar gagal diunduh), abaikan sebagai evidence pelanggaran — nama sticker saja TIDAK cukup untuk flag.
|
||||
- Terapkan standar yang lebih longgar untuk konten kartun/meme dibanding foto/video nyata.
|
||||
|
||||
Sticker yang berhasil diunduh WAJIB diperlakukan sebagai image evidence, bukan sekadar nama sticker.
|
||||
Jangan abaikan link: gunakan isi web, preview image, atau hasil analisis media link bila tersedia.
|
||||
`;
|
||||
|
||||
@@ -830,7 +933,8 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
||||
const mediaAnalyses = messageMediaAnalysisMap.get(msg.id) ?? [];
|
||||
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
||||
const webContext =
|
||||
webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
|
||||
const textEvidence = textEvidenceMap.get(msg.id) ?? "";
|
||||
const textContext = textEvidence ? `\n${textEvidence}` : "";
|
||||
const mediaAnalysisContext =
|
||||
@@ -838,11 +942,17 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
|
||||
const mediaEvidence = extractMessageMediaEvidence(msg.metadata);
|
||||
const mediaContext = [
|
||||
mediaEvidence.stickers.length > 0
|
||||
? `[sticker evidence: ${mediaEvidence.stickers.map((s) => `${s.name} (${s.url})`).join(" | ")}]`
|
||||
? mediaEvidence.stickers
|
||||
.map((s) => buildStickerTextOnlyWarning(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(" | "))
|
||||
.map((e) =>
|
||||
[e.title, e.description, e.url, e.image, e.thumbnail]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
)
|
||||
.join(" || ")}]`
|
||||
: null,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const logger = createChildLogger("sticker-cache");
|
||||
|
||||
export interface StickerCacheEntry {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
fetchedAt: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CacheIndexEntry {
|
||||
file: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface CacheIndex {
|
||||
entries: Record<string, CacheIndexEntry>;
|
||||
totalSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface StickerCacheOptions {
|
||||
cacheDir: string;
|
||||
maxSizeBytes: number;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
let cacheDir = "";
|
||||
let maxSizeBytes = 0;
|
||||
let ttlMs = 7 * 24 * 60 * 60 * 1000; // 7 days default
|
||||
let index: CacheIndex = { entries: {}, totalSizeBytes: 0 };
|
||||
let ready = false;
|
||||
|
||||
function sanitizeKey(name: string): string {
|
||||
return encodeURIComponent(name).replace(/%/g, "_");
|
||||
}
|
||||
|
||||
async function loadIndex(): Promise<CacheIndex> {
|
||||
try {
|
||||
const raw = await readFile(join(cacheDir, "index.json"), "utf-8");
|
||||
return JSON.parse(raw) as CacheIndex;
|
||||
} catch {
|
||||
return { entries: {}, totalSizeBytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIndex(idx: CacheIndex): Promise<void> {
|
||||
await writeFile(
|
||||
join(cacheDir, "index.json"),
|
||||
JSON.stringify(idx, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the sticker cache: create directory, load index.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export async function initStickerCache(
|
||||
opts: StickerCacheOptions,
|
||||
): Promise<void> {
|
||||
if (ready) return;
|
||||
cacheDir = opts.cacheDir;
|
||||
maxSizeBytes = opts.maxSizeBytes;
|
||||
ttlMs = opts.ttlMs ?? 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
index = await loadIndex();
|
||||
|
||||
// Prune expired entries on startup
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const [key, meta] of Object.entries(index.entries)) {
|
||||
if (now - meta.fetchedAt > ttlMs) {
|
||||
await unlink(join(cacheDir, meta.file)).catch(() => {});
|
||||
index.totalSizeBytes -= meta.size;
|
||||
delete index.entries[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await saveIndex(index);
|
||||
|
||||
ready = true;
|
||||
logger.info(
|
||||
{
|
||||
entryCount: Object.keys(index.entries).length,
|
||||
totalSizeBytes: index.totalSizeBytes,
|
||||
},
|
||||
"Sticker cache initialized",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a sticker image by name. Returns null on miss or TTL expiry.
|
||||
*/
|
||||
export async function getStickerFromCache(
|
||||
stickerName: string,
|
||||
): Promise<StickerCacheEntry | null> {
|
||||
if (!ready) return null;
|
||||
|
||||
const key = sanitizeKey(stickerName);
|
||||
const meta = index.entries[key];
|
||||
if (!meta) return null;
|
||||
|
||||
// TTL check
|
||||
if (Date.now() - meta.fetchedAt > ttlMs) {
|
||||
await unlink(join(cacheDir, meta.file)).catch(() => {});
|
||||
index.totalSizeBytes -= meta.size;
|
||||
delete index.entries[key];
|
||||
await saveIndex(index);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(join(cacheDir, meta.file), "utf-8");
|
||||
return {
|
||||
base64: raw,
|
||||
mimeType: meta.mimeType,
|
||||
fetchedAt: meta.fetchedAt,
|
||||
size: meta.size,
|
||||
};
|
||||
} catch {
|
||||
// File missing — clean up index entry
|
||||
delete index.entries[key];
|
||||
await saveIndex(index);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a sticker image in the cache. Fires and forgets — never blocks.
|
||||
*/
|
||||
export async function setStickerInCache(
|
||||
stickerName: string,
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
): Promise<void> {
|
||||
if (!ready) return;
|
||||
|
||||
const key = sanitizeKey(stickerName);
|
||||
const fileName = `${key}.dat`;
|
||||
const size = Buffer.byteLength(base64, "utf-8");
|
||||
|
||||
// Evict if needed
|
||||
await evictIfNeeded(size);
|
||||
|
||||
try {
|
||||
await writeFile(join(cacheDir, fileName), base64, "utf-8");
|
||||
index.entries[key] = {
|
||||
file: fileName,
|
||||
mimeType,
|
||||
size,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
index.totalSizeBytes += size;
|
||||
await saveIndex(index);
|
||||
logger.debug({ stickerName, size }, "Sticker cached");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ stickerName, error: err instanceof Error ? err.message : String(err) },
|
||||
"Failed to write sticker to cache",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function evictIfNeeded(newSize: number): Promise<void> {
|
||||
while (index.totalSizeBytes + newSize > maxSizeBytes) {
|
||||
// Find oldest entry
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, meta] of Object.entries(index.entries)) {
|
||||
if (meta.fetchedAt < oldestTime) {
|
||||
oldestTime = meta.fetchedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (!oldestKey) break;
|
||||
|
||||
const meta = index.entries[oldestKey];
|
||||
await unlink(join(cacheDir, meta.file)).catch(() => {});
|
||||
index.totalSizeBytes -= meta.size;
|
||||
delete index.entries[oldestKey];
|
||||
}
|
||||
await saveIndex(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current cache stats for observability.
|
||||
*/
|
||||
export function getStickerCacheStats(): {
|
||||
entryCount: number;
|
||||
totalSizeBytes: number;
|
||||
} {
|
||||
return {
|
||||
entryCount: Object.keys(index.entries).length,
|
||||
totalSizeBytes: index.totalSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache has been initialized.
|
||||
*/
|
||||
export function isStickerCacheReady(): boolean {
|
||||
return ready;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Sticker-specific prompt templates for AI moderation.
|
||||
*
|
||||
* Discord stickers are cartoon/meme artwork — not real photos.
|
||||
* These prompts give the LLM proper context to avoid false-positive flags
|
||||
* based solely on sticker names or cartoon imagery.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prompt used when a sticker image was successfully downloaded (from cache
|
||||
* or network) and is being sent to the vision LLM as a base64 image.
|
||||
*
|
||||
* Explains that stickers are cartoon art, not documentation of real events,
|
||||
* and instructs the model to apply looser standards for cartoon content.
|
||||
*/
|
||||
export function buildStickerVisionPrompt(
|
||||
stickerName: string,
|
||||
messageId: string,
|
||||
): string {
|
||||
return [
|
||||
`Analisis sticker Discord berikut sebagai evidence moderasi.`,
|
||||
`Sticker "${stickerName}" berasal dari pesan id=${messageId}.`,
|
||||
``,
|
||||
`PENTING — Konteks Sticker:`,
|
||||
`- Sticker Discord adalah gambar KARTUN/MEME/ILUSTRASI, BUKAN foto atau video nyata.`,
|
||||
`- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan.`,
|
||||
`- Gambar di sticker bisa menampilkan adegan yang terlihat "keras" (tokoh kartun menginjak sesuatu, ledakan komik, senjata kartun, tokoh berantem) — itu SENI KARTUN, bukan dokumentasi kekerasan atau ancaman nyata.`,
|
||||
`- Teks di sticker sering berupa lelucon, sindiran, atau ekspresi khas komunitas — bukan ancaman literal.`,
|
||||
``,
|
||||
`Jelaskan isi visual, teks yang terlihat, dan konteks risiko.`,
|
||||
`Terapkan standar yang lebih longgar untuk konten kartun/meme:`,
|
||||
`- Adegan kartun yang terlihat "keras" ≠ kekerasan nyata → jangan flag "violence" kecuali jelas menargetkan individu/kelompok nyata dengan ancaman serius.`,
|
||||
`- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat") adalah konteks satir/kartun, bukan bukti pelanggaran.`,
|
||||
`- Humor/satir/politik kartun ≠ SARA atau hate speech.`,
|
||||
`- Sticker yang menampilkan tokoh kartun dalam pose agresif adalah ekspresi/emosi umum di Discord, bukan harassment.`,
|
||||
``,
|
||||
`Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for text-only evidence when a sticker image failed to download.
|
||||
*
|
||||
* Returns a formatted string that explicitly tells the LLM not to flag
|
||||
* based on the sticker name alone, since names can sound provocative
|
||||
* while the actual cartoon image is harmless.
|
||||
*/
|
||||
export function buildStickerTextOnlyWarning(
|
||||
stickerName: string,
|
||||
stickerUrl: string,
|
||||
): string {
|
||||
return (
|
||||
`[sticker: "${stickerName}" (${stickerUrl}) — GAMBAR GAGAL DIUNDUH. ` +
|
||||
`"${stickerName}" adalah sticker kartun/meme Discord. ` +
|
||||
`JANGAN flag berdasarkan nama sticker saja tanpa gambar visual. ` +
|
||||
`Sticker Discord adalah seni kartun/ekspresi humor, bukan foto nyata. ` +
|
||||
`Nama yang terdengar provokatif adalah hal umum untuk sticker satir/humor di Discord.]`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user