From 1397380fe941114f720f0a2686bf4922d98cbe2b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 22 Aug 2026 16:17:55 +0700 Subject: [PATCH] fix(ai): pertahankan status warn di cache moderasi + bersihkan prompt stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeStoredStatus(): exact-hash & semantic (Qdrant/PG) cache reader sebelumnya menipiskan 'warn' jadi 'flagged'/'clean' (type narrowing legacy clean|flagged) — merusak gating auto-delete & label dashboard. Kini status tersimpan dipertahankan penuh (clean/warn/flagged). - prompts: hapus referensi yang tak pernah di-inject, SearXNG -> Wikipedia (sudah migrasi), referensi section yang tak ada, typo 'secifik', dan baris list rusak '|-'. - moderationBuilders: buang dead code buildUserProfilesBlock/ buildUserProfileRef/UserProfileEntry/buildUserHistoryXml (tanpa caller produksi sejak context minimization) + test-nya. - test baru: tests/storedStatusNormalization.test.ts (regresi warn). --- .../ai-moderation/moderationBuilders.ts | 77 +------------ .../modules/ai-moderation/prompts/output.ts | 20 ++-- .../modules/ai-moderation/prompts/rules.ts | 2 +- .../modules/ai-moderation/prompts/system.ts | 4 +- .../modules/ai-moderation/textCacheStore.ts | 106 ++++++++++-------- .../tests/contextEnrichment.test.ts | 86 +------------- .../tests/storedStatusNormalization.test.ts | 37 ++++++ 7 files changed, 115 insertions(+), 217 deletions(-) create mode 100644 services/discord-gateway/tests/storedStatusNormalization.test.ts diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts index 9b57989..f6ff10d 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts @@ -75,8 +75,6 @@ export function buildConversationContextBlock(input: { // huge paste (stack traces, log dumps, copypasta). Truncation is explicit so // the model never mistakes the cut for a real message boundary. // --------------------------------------------------------------------------- - -/** Max characters of a message's content sent to the LLM `` payload. */ export const AI_CONTENT_MAX_CHARS = 4000; /** Marker appended when a message is longer than AI_CONTENT_MAX_CHARS. */ @@ -89,79 +87,12 @@ export function truncateForAi(content: string): string { } // --------------------------------------------------------------------------- -// User profile deduplication — a batch can contain many messages from the -// same user. Instead of repeating the (up to 3000-char) profile summary on -// every message, emit a single map per batch and reference -// entries per message with . +// User profile deduplication — REMOVED (2026-08-22). +// Per-user profile/history context was stripped from the moderation prompt +// (context minimization): buildUserProfilesBlock / buildUserProfileRef / +// UserProfileEntry / buildUserHistoryXml had no remaining production callers. // --------------------------------------------------------------------------- -export interface UserProfileEntry { - /** Profile summary text (from user_profiles.profile_summary). */ - text: string; - /** Epoch ms when the profile was last generated — staleness signal for - * the LLM (a profile from months ago may not reflect current behavior). */ - asOf?: number | null; -} - -/** Build a deduplicated `` map block, keyed by Discord user id. */ -export function buildUserProfilesBlock( - profiles: ReadonlyMap, -): string { - const entries = Array.from(profiles.entries()).filter( - ([, entry]) => entry.text.trim().length > 0, - ); - if (entries.length === 0) return ""; - const lines = entries.map(([userId, entry]) => { - const asOfAttr = - typeof entry.asOf === "number" && entry.asOf > 0 - ? ` as_of="${new Date(entry.asOf).toISOString()}"` - : ""; - return ` ${sanitizeAiContent(entry.text)}`; - }); - return `\n${lines.join("\n")}\n`; -} - -/** Per-message reference tag pointing at an entry in the `` map. */ -export function buildUserProfileRef(userId: string): string { - return ``; -} - -// --------------------------------------------------------------------------- -// Per-user history context (last flagged messages only — no trust model). -// context to AI moderation. -// --------------------------------------------------------------------------- -const DAY_MS = 24 * 60 * 60 * 1000; - -/** - * Builds an optional `` block (last flagged messages) from - * getUserRecentInfractions rows. Only emitted when there is real history — - * lets the LLM see the PATTERN (e.g. the same scam link posted repeatedly) - * without treating old flags as proof for the current message. - */ -export function buildUserHistoryXml( - history: Array<{ - content: string; - severity: string | null; - created_at: number; - }>, - now: number = Date.now(), -): string { - const filtered = history.filter((h) => h.content?.trim()); - if (filtered.length === 0) return ""; - const lines = filtered.map((h) => { - const daysAgo = Math.max(0, Math.floor((now - h.created_at) / DAY_MS)); - const severityAttr = h.severity - ? ` severity="${escapeXml(h.severity)}"` - : ""; - const snippet = - h.content.length > 100 - ? `${h.content.slice(0, 100).trimEnd()}…` - : h.content; - return ` ${escapeXml(snippet)}`; - }); - return `\n${lines.join("\n")}\n`; -} - /** * Whether the message author was a bot (captured in metadata.author.bot). * Bot posts (logging bots, webhook-style automation) deserve different diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts index 8d54399..0e61211 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts @@ -45,18 +45,18 @@ Wajib sebutkan ISI/KONTEN spesifik apa yang dibicarakan pengirim — bukan templ - **Teks + gambar**: "Pengirim mengirim screenshot chat sambil membahas makanan favorit. Gambar dan teks sama-sama tentang percakapan sehari-hari. Tidak ada pelanggaran." ✓ / "Pesan berisi teks dan gambar tanpa pelanggaran." ✗ ### Per kasus: -|- **Melanggar:** "Pengirim . . ." -|- **conflict_instigation:** "Pengirim . . Diberi peringatan karena berpotensi memicu drama." -|- **Username ofensif (pesan bersih):** "Pengirim memiliki username yang . Isi pesan hanya . Diberi warning ringan." — (pesan memperkuat): " + isi pesan memperkuat tone kebencian. Pelanggaran berat." -|- **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan . . ." -|- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. . Diberi peringatan karena spam berulang." — nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran. -|- **sexual_deviation:** "Pengirim . . Melanggar kebijikan server." -|- **SARA/penistaan agama:** "Pengirim . . Melanggar kebijikan SARA." — JANGAN gunakan kata "bercanda" untuk SARA. +- **Melanggar:** "Pengirim . . ." +- **conflict_instigation:** "Pengirim . . Diberi peringatan karena berpotensi memicu drama." +- **Username ofensif (pesan bersih):** "Pengirim memiliki username yang . Isi pesan hanya . Diberi warning ringan." — (pesan memperkuat): " + isi pesan memperkuat tone kebencian. Pelanggaran berat." +- **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan . . ." +- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. . Diberi peringatan karena spam berulang." — nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran. +- **sexual_deviation:** "Pengirim . . Melanggar kebijikan server." +- **SARA/penistaan agama:** "Pengirim . . Melanggar kebijikan SARA." — JANGAN gunakan kata "bercanda" untuk SARA. -**CRITICAL — dilarang menulis analysis generik:** JANGAN PERNAH menulis "Pesan hanya berisi...", "Tidak ada indikasi pelanggaran", atau template seperti "Pengirim mengirimkan sebuah file GIF tanpa pelanggaran." Selalu sebutkan ISI/KONTEN secifik, apa yang dibicarakan, apa yang terlihat. +**CRITICAL — dilarang menulis analysis generik:** JANGAN PERNAH menulis "Pesan hanya berisi...", "Tidak ada indikasi pelanggaran", atau template seperti "Pengirim mengirimkan sebuah file GIF tanpa pelanggaran." Selalu sebutkan ISI/KONTEN spesifik, apa yang dibicarakan, apa yang terlihat. -|- **BALASAN (reply):** jelaskan konteks balasannya (apa dibicarakan, siapa dibalas tanpa nama, bagaimana tanggapan pengirim). -|- Gunakan Media analysis untuk mendeskripsikan gambar. Analisis harus MEMBERI KONTEKS, bukan hanya status.`; +- **BALASAN (reply):** jelaskan konteks balasannya (apa dibicarakan, siapa dibalas tanpa nama, bagaimana tanggapan pengirim). +- Gunakan Media analysis untuk mendeskripsikan gambar. Analisis harus MEMBERI KONTEKS, bukan hanya status.`; // --------------------------------------------------------------------------- // Sanitize AI-generated content (channel culture / user profile) to prevent diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts index 9a23666..b4f1fa5 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts @@ -48,7 +48,7 @@ Kata alat kelamin/anatomi seksual (kontol, memek, titten, tit, dick) atau istila - Ageisme ("dasar bocil", "tau aja lo tua") → hate_speech / harassment. - Diskriminasi fisik ("gendut", "iteman", "cungkring") → harassment jika terarah. - Serangan personal, penghinaan, merendahkan = tidak ditoleransi. Perbedaan pendapat wajar. -- **PESAN DINILAI SECARA STANDALONE:** Setiap pesan baru dinilai BERDASARKAN ISINYA SENDIRI. (jika ada) HANYA untuk mendeteksi POLA PENGULANGAN dengan JAMAK (spam link yang SAMA, provokasi berulang yang MENGANDALKAN KONTEN YANG SAMA). JANGAN gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat pelanggaran sebelumnya. Jika pesan tidak mengandung unsur yang BERPANDUAN PADA riwayat → tetap CLEAN. +- **PESAN DINILAI SECARA STANDALONE:** Setiap pesan baru dinilai BERDASARKAN ISINYA SENDIRI. Tidak ada data profil/reputasi per-user di payload — nilai murni dari isi pesan + konteks percakapan. Pengulangan teks sama dalam satu batch ditandai atribut repetitions="N" pada (sinyal spam), bukan dari riwayat lampau. ## LARANGAN BERAT (ZERO TOLERANCE) - **LGBT:** Segala promosi, diskusi, pengakuan orientasi, coming out, atau curhat personal tentang LGBT WAJIB di-flag "sexual_deviation". Tidak ada pengecualian. (Lihat juga pohon keputusan #3.) diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts index eec9ad2..6929aa9 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts @@ -125,14 +125,14 @@ function buildSystemPromptCore( `- : metadata channel/thread (channel_name, thread_name, topic, nsfw, age_restricted). topic = tujuan resmi channel; gunakan menilai kesesuaian pesan.\n` + `- : obrolan SEBELUM target. Baris pertama "[conversation_flow] status=... context_msgs=... dropped=..." = metadata sistem (ongoing/sparse/cold_start), BUKAN pesan dinilai. Baris "[context] id=... time=... user=...: isi" = konteks, BUKAN target.\n` + `- Tidak ada data profil/reputasi per-user di context — nilai tiap pesan murni dari isinya + + + .\n` + - `- /: bukti web (prioritas tertinggi). : definisi kata/slang/jargon (SearXNG) — pakai pahami kata asing, JANGAN tebak arti.\n` + + `- /: bukti web (prioritas tertinggi). : definisi kata/slang/jargon (Wikipedia) — pakai pahami kata asing, JANGAN tebak arti.\n` + `- : pesan TARGET yang WAJIB dinilai. Atribut : id, user, time (ISO), repetitions (N = teks sama muncul N× di batch → sinyal spam), bot (true = bot), edited (true = hasil edit setelah posting → evasi potensial).`, ); parts.push( `## Framing & Aturan Konteks\n` + `- Hasilkan SATU hasil per message_id — jangan gabung, lewati, atau karang id.\n` + - `- Setiap target dinilai BERDASARKAN ISINYA SENDIRI. Konteks memengaruhi interpretasi, tapi TIDAK menggantikan isi pesan. Profil/riwayat = REFERENSI personalisasi, BUKAN bukti pelanggaran (lihat "PERSONALITY & MEMORI").\n` + + `- Setiap target dinilai BERDASARKAN ISINYA SENDIRI. Konteks memengaruhi interpretasi, tapi TIDAK menggantikan isi pesan.\n` + `- Marker "[pesan dipotong: terlalu panjang]" = TARGET dipotong; "[konteks dipotong: ...]" = konteks dipotong. Nilai dari bagian terlihat; pemotongan BUKAN pelanggaran/evasi.\n` + `- time= = kapan dikirim (rekonsiliasi spam beruntun / bump pesan lama). bot=true = otomatisasi, bukan pelanggaran personal.`, ); diff --git a/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts b/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts index 8852850..709deae 100644 --- a/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts +++ b/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts @@ -260,11 +260,31 @@ export async function invalidateTextModerationCache( } /** - * Lookup a cached moderation result for a text content. - * Returns the stored result fields or null. + * Normalize a stored verdict status to the full three-state union. + * + * Bug history (2026-08-22): both cache readers narrowed their types to + * "clean" | "flagged", so a stored "warn" verdict fell into the legacy + * `flags.length === 0 ? clean : flagged` branch and was served back as + * FLAGGED (breaking auto-delete gating + dashboard labels). New entries + * store the exact status; legacy rows without one derive from flags. */ -export async function getCachedTextModeration(cacheKey: string): Promise<{ - status: "clean" | "flagged"; +export function normalizeStoredStatus( + storedStatus: string | undefined, + flags: string[], +): "clean" | "warn" | "flagged" { + if ( + storedStatus === "clean" || + storedStatus === "warn" || + storedStatus === "flagged" + ) { + return storedStatus; + } + return flags.length === 0 ? "clean" : "flagged"; +} + +/** Shape shared by every moderation-cache read path. */ +export interface StoredModerationVerdict { + status: "clean" | "warn" | "flagged"; flags: string[]; score: number; analysis: string; @@ -272,7 +292,15 @@ export async function getCachedTextModeration(cacheKey: string): Promise<{ severity: string; confidence: number; recommendedAction: string; -} | null> { +} + +/** + * Lookup a cached moderation result for a text content. + * Returns the stored result fields or null. + */ +export async function getCachedTextModeration( + cacheKey: string, +): Promise { try { const row = await executeGet( `SELECT flags, source, analyzed_at, expires_at, hit_count @@ -285,14 +313,12 @@ export async function getCachedTextModeration(cacheKey: string): Promise<{ const parsed = JSON.parse(row.flags) as Record; const flags = (parsed.flags as string[]) ?? []; - // Use stored status if available (new entries), otherwise derive from flags (legacy compatibility) - const storedStatus = parsed.status as string | undefined; - const status: "clean" | "flagged" = - storedStatus === "clean" || storedStatus === "flagged" - ? storedStatus - : flags.length === 0 - ? "clean" - : "flagged"; + // Use stored status if available (new entries), otherwise derive from + // flags (legacy compatibility). "warn" must survive the round-trip. + const status = normalizeStoredStatus( + parsed.status as string | undefined, + flags, + ); return { status, @@ -321,18 +347,12 @@ export async function getCachedTextModeration(cacheKey: string): Promise<{ export function parseQdrantVerdict( payload: QdrantVerdictPayload, similarity: number, -): { - text: string; - similarity: number; - status: "clean" | "warn" | "flagged"; - flags: string[]; - score: number; - analysis: string; - categories: string[]; - severity: string; - confidence: number; - recommendedAction: string; -} | null { +): + | (StoredModerationVerdict & { + text: string; + similarity: number; + }) + | null { let parsed: Record; try { parsed = JSON.parse(payload.flags) as Record; @@ -341,17 +361,17 @@ export function parseQdrantVerdict( } if (!parsed || typeof parsed !== "object") return null; - const storedStatus = (parsed.status as string) ?? "clean"; - const status: "clean" | "warn" | "flagged" = - storedStatus === "warn" || storedStatus === "flagged" - ? storedStatus - : "clean"; + const flags = (parsed.flags as string[]) ?? []; + const status = normalizeStoredStatus( + parsed.status as string | undefined, + flags, + ); return { text: payload.text, similarity, status, - flags: (parsed.flags as string[]) ?? [], + flags, score: (parsed.score as number) ?? 0, analysis: (parsed.analysis as string) ?? "", categories: (parsed.categories as string[]) ?? [], @@ -373,18 +393,9 @@ export async function findSimilarTextModeration( embedding: number[], minSimilarity: number, limit: number, -): Promise<{ - text: string; - similarity: number; - status: "clean" | "warn" | "flagged"; - flags: string[]; - score: number; - analysis: string; - categories: string[]; - severity: string; - confidence: number; - recommendedAction: string; -} | null> { +): Promise< + (StoredModerationVerdict & { text: string; similarity: number }) | null +> { // Qdrant path (primary) if (isQdrantConfigured()) { const hits = await searchQdrant(embedding, limit, minSimilarity); @@ -443,11 +454,10 @@ export async function findSimilarTextModeration( const hit = candidates[match.index]; const parsed = hit.parsed; const flags = (parsed.flags as string[]) ?? []; - const storedStatus = (parsed.status as string) ?? "clean"; - const status: "clean" | "warn" | "flagged" = - storedStatus === "warn" || storedStatus === "flagged" - ? storedStatus - : "clean"; + const status = normalizeStoredStatus( + parsed.status as string | undefined, + flags, + ); return { text: hit.text, similarity: match.similarity, diff --git a/services/discord-gateway/tests/contextEnrichment.test.ts b/services/discord-gateway/tests/contextEnrichment.test.ts index e7a5eea..b0dc199 100644 --- a/services/discord-gateway/tests/contextEnrichment.test.ts +++ b/services/discord-gateway/tests/contextEnrichment.test.ts @@ -1,11 +1,10 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Context enrichment builders — , as_of, -// bot/edited detection (pure, no DB) +// Context enrichment builders — bot/edited detection (pure, no DB) +// (buildUserHistoryXml / buildUserProfilesBlock were removed with the +// per-user context minimization; their tests went with them.) // ═══════════════════════════════════════════════════════════════════════════ import { describe, expect, it } from "vitest"; import { - buildUserHistoryXml, - buildUserProfilesBlock, resolveIsBot, resolveIsEdited, } from "../src/modules/ai-moderation/moderationBuilders.js"; @@ -39,85 +38,6 @@ function msg(overrides: Partial = {}): MessageRecord { }; } -const DAY_MS = 24 * 60 * 60 * 1000; - -describe("buildUserHistoryXml — last flagged messages for repeat offenders", () => { - it("returns empty when there is no real history", () => { - expect(buildUserHistoryXml([])).toBe(""); - expect( - buildUserHistoryXml([{ content: " ", severity: "low", created_at: 1 }]), - ).toBe(""); - }); - - it("renders rows with severity and recency", () => { - const xml = buildUserHistoryXml( - [ - { - content: "beli barang murah disini https://scam.example", - severity: "high", - created_at: NOW - 3 * DAY_MS, - }, - ], - NOW, - ); - expect(xml).toContain(""); - expect(xml).toContain('severity="high"'); - expect(xml).toContain('time_ago_days="3"'); - expect(xml).toContain("beli barang murah disini"); - }); - - it("caps long snippets and XML-escapes content", () => { - const xml = buildUserHistoryXml( - [ - { - content: "x".repeat(300), - severity: "low", - created_at: NOW - DAY_MS, - }, - ], - NOW, - ); - expect(xml.length).toBeLessThan(250); - }); -}); - -describe("buildUserProfilesBlock — deduplicated map with staleness", () => { - it("emits as_of when the profile has a last-generated timestamp", () => { - const block = buildUserProfilesBlock( - new Map([ - [ - "u1", - { - text: "Developer teknis, bahasa Indonesia", - asOf: NOW - 3 * DAY_MS, - }, - ], - ]), - ); - expect(block).toContain(' { - const block = buildUserProfilesBlock( - new Map([ - ["u1", { text: "profil aktif", asOf: null }], - ["u2", { text: " " }], - ]), - ); - expect(block).toContain('user_id="u1"'); - expect(block).not.toContain("as_of"); - expect(block).not.toContain("u2"); - }); - - it("returns empty for no profiles", () => { - expect(buildUserProfilesBlock(new Map())).toBe(""); - }); -}); - describe("resolveIsBot / resolveIsEdited — message flags", () => { it("reads author.bot from captured metadata", () => { const bot = msg({ diff --git a/services/discord-gateway/tests/storedStatusNormalization.test.ts b/services/discord-gateway/tests/storedStatusNormalization.test.ts new file mode 100644 index 0000000..31bb513 --- /dev/null +++ b/services/discord-gateway/tests/storedStatusNormalization.test.ts @@ -0,0 +1,37 @@ +// ═══════════════════════════════════════════════════════════════════════════ +// Stored-status normalization — "warn" verdicts must survive the cache +// ═══════════════════════════════════════════════════════════════════════════ +// Bug (2026-08-22): getCachedTextModeration() narrowed its return type to +// "clean" | "flagged". A stored "warn" verdict with flags (e.g. +// ["conflict_instigation"]) fell into the legacy `flags.length === 0 ? +// clean : flagged` branch and was read back as FLAGGED. Downstream this +// broke auto-delete eligibility gating and mislabelled warnings on the +// dashboard. parseQdrantVerdict had the same narrowing (warn → clean). +// +// Fix: normalizeStoredStatus() accepts the full clean/warn/flagged union in +// BOTH readers; unknown/legacy values still derive from flags. +import { describe, expect, it } from "vitest"; +import { normalizeStoredStatus } from "../src/modules/ai-moderation/textCacheStore.js"; + +describe("normalizeStoredStatus — warn survives cache round-trip", () => { + it("keeps a stored 'warn' status as 'warn'", () => { + expect(normalizeStoredStatus("warn", ["conflict_instigation"])).toBe( + "warn", + ); + }); + + it("keeps stored 'clean' and 'flagged' unchanged", () => { + expect(normalizeStoredStatus("clean", [])).toBe("clean"); + expect(normalizeStoredStatus("flagged", ["sara"])).toBe("flagged"); + }); + + it("derives from flags for legacy entries without a stored status", () => { + expect(normalizeStoredStatus(undefined, [])).toBe("clean"); + expect(normalizeStoredStatus(undefined, ["spam"])).toBe("flagged"); + }); + + it("treats an unknown stored status like a legacy entry", () => { + expect(normalizeStoredStatus("processing", [])).toBe("clean"); + expect(normalizeStoredStatus("processing", ["spam"])).toBe("flagged"); + }); +});