diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index 0dc5570..75b69cc 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -239,6 +239,7 @@ function getAnalysisWorkerUrl(): URL { const workerPool = new Piscina({ filename: fileURLToPath(getAnalysisWorkerUrl()), execArgv: process.execArgv, + maxThreads: config.PISCINA_MAX_THREADS ?? availableParallelism(), }); interface AnalysisWorkerResponse { @@ -275,8 +276,8 @@ export function pickBatchWithinBudget( for (const msg of messages) { const content = msg.edited_content ?? msg.content; - // Rough token estimate: ~3 chars per token + metadata overhead - const msgTokens = Math.ceil(content.length / 3) + tokensPerMessage; + // Accurate token count via tiktoken (+ overhead for JSON structure) + const msgTokens = estimateTokens(content) + tokensPerMessage; if (usedTokens + msgTokens <= maxTokens) { batch.push(msg); diff --git a/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts b/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts index ec8a47a..cd59894 100644 --- a/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts @@ -26,6 +26,55 @@ interface BadwordCacheEntry { const badwordCache = new Map(); const inFlightBadwordLookups = new Map>(); +// --------------------------------------------------------------------------- +// Local rule-based pre-filter — short-circuits definitively safe messages +// without calling the LLM badword detection API. +// Conservative: only flags messages that are 100% certainly clean. +// --------------------------------------------------------------------------- + +const SAFE_PATTERNS: Array<{ test: (text: string) => boolean; reason: string }> = [ + { + // Pure laughter patterns + test: (t) => /^(wkwk+|w+kw+k+|wkwkw+|haha+|hehe+|hihi+|huhu+|xixi+|wakak+|awkwa+)$/i.test(t), + reason: "laughter pattern", + }, + { + // Single-word affirmatives (common in Indonesian Discord) + test: (t) => /^(ok|oke|okay|sip|siap|aman|mantap|gas|gass|gaskeun|santuy|gaskan|lah|wih|wah|eh|nah|loh|hmm|hm|heh)$/i.test(t), + reason: "single-word affirmative", + }, + { + // Greetings / common short expressions + test: (t) => /^(hai|halo|hello|hi|oi|woy|woi|pagi|siang|sore|malam|mlm|p|w|L|F|gws|thx|thks|makasih|ty|thanks|yw|sama-sama|ok sip|ok bang|siap bang)$/i.test(t), + reason: "greeting/common expression", + }, + { + // Very short messages (1-2 characters) — reactions, single letters + test: (t) => t.length <= 2, + reason: "very short message (1-2 chars)", + }, + { + // Pure numeric or pure punctuation + test: (t) => /^[\d\s.,!?;:'"()\-_]+$/.test(t), + reason: "numeric/punctuation only", + }, +]; + +/** + * Checks whether a text message is definitively safe and does not need + * LLM-based badword detection. This is a conservative local pre-filter + * — it only returns true for patterns that CANNOT be violations. + */ +export function isDefinitivelySafe(text: string): boolean { + // Normalize: strip Discord custom emoji, trim whitespace + const { text: normalized } = normalizeDiscordCustomEmoji(text); + const trimmed = normalized.trim(); + + if (trimmed.length === 0) return true; + + return SAFE_PATTERNS.some((p) => p.test(trimmed)); +} + export interface ModerationTextEvidence { raw: string; normalized: string; @@ -123,6 +172,11 @@ export async function detectIndonesianBadwords( return cached; } + // ── Tier 0: Local rule-based pre-filter (fastest — no API call) ── + if (isDefinitivelySafe(text)) { + return []; + } + // De-duplicate concurrent lookups const inFlight = inFlightBadwordLookups.get(cacheKey); if (inFlight) { diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index e4f9d0a..253f5c3 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -1,5 +1,6 @@ import { createChildLogger } from "@bete/shared/logger"; import { retryWithBackoff } from "@bete/shared/utils"; +import { LRUCache } from "lru-cache"; import type { ChatCompletion } from "openai/resources/chat/completions"; import { AbortError } from "p-retry"; import { z } from "zod"; @@ -28,11 +29,14 @@ import { buildStickerVisionPrompt, } from "./stickerPrompt.js"; import { + computeImagePhash, getCachedMediaAnalysis, + getCachedMediaByPhash, makeCustomEmojiCacheKey, makeImageCacheKey, makeStickerCacheKey, upsertCachedMediaAnalysis, + upsertCachedMediaByPhash, } from "./textCacheStore.js"; import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts b/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts index 4993d5c..859792e 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts @@ -184,9 +184,15 @@ CRITICAL: // Composer: assembles all sections with XML delimiters // --------------------------------------------------------------------------- +/** Prompt mode — determines which few-shot example section is included. */ +export type PromptMode = "text" | "media" | "mixed"; + export interface BuildSystemPromptOptions { contextText: string; - includeMediaInstructions: boolean; + /** Prompt mode — determines which sections are included. */ + mode: PromptMode; + /** @deprecated Use `mode` instead. */ + includeMediaInstructions?: boolean; correction?: { error: string; preview: string }; }